diff --git a/api/token.go b/api/token.go index 60788f074..7f44f2c54 100644 --- a/api/token.go +++ b/api/token.go @@ -8,6 +8,7 @@ import ( "github.com/TUM-Dev/gocast/dao" "github.com/TUM-Dev/gocast/model" "github.com/TUM-Dev/gocast/tools" + "github.com/TUM-Dev/gocast/tools/tum" "github.com/gin-gonic/gin" uuid "github.com/satori/go.uuid" ) @@ -15,7 +16,8 @@ import ( func configTokenRouter(r *gin.Engine, daoWrapper dao.DaoWrapper) { routes := tokenRoutes{daoWrapper} g := r.Group("/api/token") - g.Use(tools.Admin) + g.POST("/proxy/:token", routes.fetchStreamKey) + g.Use(tools.AtLeastLecturer) g.POST("/create", routes.createToken) g.DELETE("/:id", routes.deleteToken) } @@ -26,7 +28,34 @@ type tokenRoutes struct { func (r tokenRoutes) deleteToken(c *gin.Context) { id := c.Param("id") - err := r.TokenDao.DeleteToken(id) + + foundContext, exists := c.Get("TUMLiveContext") + if !exists { + return + } + tumLiveContext := foundContext.(tools.TUMLiveContext) + + token, err := r.TokenDao.GetTokenByID(id) + if err != nil { + logger.Error("can not get token", "err", err) + _ = c.Error(tools.RequestError{ + Status: http.StatusInternalServerError, + CustomMessage: "can not get token", + Err: err, + }) + return + } + + // only the user who created the token or an admin can delete it + if token.UserID != tumLiveContext.User.ID && tumLiveContext.User.Role != model.AdminType { + _ = c.Error(tools.RequestError{ + Status: http.StatusForbidden, + CustomMessage: "not allowed to delete token", + }) + return + } + + err = r.TokenDao.DeleteToken(id) if err != nil { logger.Error("can not delete token", "err", err) _ = c.Error(tools.RequestError{ @@ -58,13 +87,22 @@ func (r tokenRoutes) createToken(c *gin.Context) { }) return } - if req.Scope != model.TokenScopeAdmin { + if req.Scope == model.TokenScopeAdmin && tumLiveContext.User.Role != model.AdminType { _ = c.Error(tools.RequestError{ Status: http.StatusBadRequest, CustomMessage: "not an admin", }) return } + + if req.Scope != model.TokenScopeAdmin && req.Scope != model.TokenScopeLecturer { + _ = c.Error(tools.RequestError{ + Status: http.StatusBadRequest, + CustomMessage: "invalid scope", + }) + return + } + tokenStr := uuid.NewV4().String() expires := sql.NullTime{Valid: req.Expires != nil} if req.Expires != nil { @@ -90,3 +128,67 @@ func (r tokenRoutes) createToken(c *gin.Context) { "token": tokenStr, }) } + +// This is used by the proxy to get the stream key of the next stream of the lecturer given a lecturer token +// +// Proxy receives: rtmp://proxy.example.com/<lecturer-token> +// or: rtmp://proxy.example.com/<lecturer-token>?slug=ABC-123 <-- optional slug parameter in case the lecturer is streaming multiple courses simultaneously +// +// Proxy returns: rtmp://ingest.example.com/ABC-123?secret=610f609e4a2c43ac8a6d648177472b17 +func (r *tokenRoutes) fetchStreamKey(c *gin.Context) { + // Optional slug parameter to get the stream key of a specific course (in case the lecturer is streaming multiple courses simultaneously) + slug := c.Query("slug") + t := c.Param("token") + + // Get user from token + token, err := r.TokenDao.GetToken(t) + if err != nil { + _ = c.Error(tools.RequestError{ + Status: http.StatusBadRequest, + CustomMessage: "invalid token", + }) + return + } + + // Only tokens of type lecturer are allowed to start streaming + if token.Scope != model.TokenScopeLecturer { + _ = c.Error(tools.RequestError{ + Status: http.StatusUnauthorized, + CustomMessage: "invalid scope", + }) + return + } + + // Get user and check if he has the right to start a stream + user, err := r.UsersDao.GetUserByID(c, token.UserID) + if err != nil { + _ = c.Error(tools.RequestError{ + Status: http.StatusInternalServerError, + CustomMessage: "could not get user", + Err: err, + }) + return + + } + if user.Role != model.LecturerType && user.Role != model.AdminType { + _ = c.Error(tools.RequestError{ + Status: http.StatusUnauthorized, + CustomMessage: "user is not a lecturer or admin", + }) + return + } + + // Find current/next stream and course of which the user is a lecturer + year, term := tum.GetCurrentSemester() + streamKey, courseSlug, err := r.StreamsDao.GetSoonStartingStreamInfo(&user, slug, year, term) + if err != nil || streamKey == "" || courseSlug == "" { + _ = c.Error(tools.RequestError{ + Status: http.StatusNotFound, + CustomMessage: "no stream found", + Err: err, + }) + return + } + + c.JSON(http.StatusOK, gin.H{"url": "" + tools.Cfg.IngestBase + "/" + courseSlug + "?secret=" + streamKey + "/" + courseSlug}) +} diff --git a/api/token_test.go b/api/token_test.go index 01c6bb5f0..0a2088809 100644 --- a/api/token_test.go +++ b/api/token_test.go @@ -88,6 +88,7 @@ func TestToken(t *testing.T) { wrapper := dao.DaoWrapper{ TokenDao: func() dao.TokenDao { tokenMock := mock_dao.NewMockTokenDao(gomock.NewController(t)) + tokenMock.EXPECT().GetTokenByID("1").Return(model.Token{}, nil).AnyTimes() tokenMock.EXPECT().DeleteToken("1").Return(errors.New("")).AnyTimes() return tokenMock }(), @@ -102,6 +103,7 @@ func TestToken(t *testing.T) { wrapper := dao.DaoWrapper{ TokenDao: func() dao.TokenDao { tokenMock := mock_dao.NewMockTokenDao(gomock.NewController(t)) + tokenMock.EXPECT().GetTokenByID("1").Return(model.Token{}, nil).AnyTimes() tokenMock.EXPECT().DeleteToken("1").Return(nil).AnyTimes() return tokenMock }(), diff --git a/config.yaml b/config.yaml index 7ded1393f..d89a8bdcf 100644 --- a/config.yaml +++ b/config.yaml @@ -103,3 +103,4 @@ meili: apiKey: MASTER_KEY vodURLTemplate: https://stream.lrz.de/vod/_definst_/mp4:tum/RBG/%s.mp4/playlist.m3u8 canonicalURL: https://tum.live +rtmpProxyURL: https://proxy.example.com diff --git a/dao/courses.go b/dao/courses.go index 7a169f0c9..a03557700 100644 --- a/dao/courses.go +++ b/dao/courses.go @@ -69,7 +69,7 @@ func (d coursesDao) CreateCourse(ctx context.Context, course *model.Course, keep func (d coursesDao) AddAdminToCourse(userID uint, courseID uint) error { defer Cache.Clear() - return DB.Exec("insert into course_admins (user_id, course_id) values (?, ?)", userID, courseID).Error + return DB.Exec("insert into course_admins (user_id, course_id) values (?, ?) on duplicate key update user_id = user_id", userID, courseID).Error } // GetCurrentOrNextLectureForCourse Gets the next lecture for a course or the lecture that is currently live. Error otherwise. diff --git a/dao/streams.go b/dao/streams.go index fb838b531..92bfcaf75 100755 --- a/dao/streams.go +++ b/dao/streams.go @@ -3,12 +3,15 @@ package dao import ( "context" "fmt" + "log/slog" "strconv" + "strings" "time" "gorm.io/gorm/clause" "github.com/TUM-Dev/gocast/model" + uuid "github.com/satori/go.uuid" "gorm.io/gorm" ) @@ -32,6 +35,7 @@ type StreamsDao interface { GetCurrentLiveNonHidden(ctx context.Context) (currentLive []model.Stream, err error) GetLiveStreamsInLectureHall(lectureHallId uint) ([]model.Stream, error) GetStreamsWithWatchState(courseID uint, userID uint) (streams []model.Stream, err error) + GetSoonStartingStreamInfo(user *model.User, slug string, year int, term string) (string, string, error) SetLectureHall(streamIDs []uint, lectureHallID uint) error UnsetLectureHall(streamIDs []uint) error @@ -305,6 +309,107 @@ func (d streamsDao) GetStreamsWithWatchState(courseID uint, userID uint) (stream return } +// GetSoonStartingStreamInfo returns the stream key, course slug and course name of an upcoming stream. +func (d streamsDao) GetSoonStartingStreamInfo(user *model.User, slug string, year int, term string) (string, string, error) { + var result struct { + CourseID uint + StreamKey string + ID string + Slug string + } + now := time.Now() + query := DB.Table("streams"). + Select("streams.course_id, streams.stream_key, streams.id, courses.slug"). + Joins("JOIN course_admins ON course_admins.course_id = streams.course_id"). + Joins("JOIN courses ON courses.id = course_admins.course_id"). + Where("courses.slug != 'TESTCOURSE' AND streams.deleted_at IS NULL AND courses.deleted_at IS NULL AND course_admins.user_id = ? AND (streams.start <= ? AND streams.end >= ?)", user.ID, now.Add(15*time.Minute), now). // Streams starting in the next 15 minutes or currently running + Or("courses.slug != 'TESTCOURSE' AND streams.deleted_at IS NULL AND courses.deleted_at IS NULL AND course_admins.user_id = ? AND (streams.end >= ? AND streams.end <= ?)", user.ID, now.Add(-15*time.Minute), now). // Streams that just finished in the last 15 minutes + Order("streams.start ASC") + + if slug != "" { + query = query.Where("courses.slug = ?", slug) + } + if year != 0 { + query = query.Where("courses.year = ?", year) + } + if term != "" { + query = query.Where("courses.teaching_term = ?", term) + } + + err := query.Limit(1).Scan(&result).Error + if err == gorm.ErrRecordNotFound || result.StreamKey == "" || result.ID == "" || result.Slug == "" { + stream, course, err := d.CreateOrGetTestStreamAndCourse(user) + if err != nil { + return "", "", err + } + return stream.StreamKey, fmt.Sprintf("%s-%d", course.Slug, stream.ID), nil + } + if err != nil { + logger.Error("Error getting soon starting stream: %v", slog.String("err", err.Error())) + return "", "", err + } + + return result.StreamKey, fmt.Sprintf("%s-%s", result.Slug, result.ID), nil +} + +// Helper method to fetch test stream and course for current user. +func (d streamsDao) CreateOrGetTestStreamAndCourse(user *model.User) (model.Stream, model.Course, error) { + course, err := d.CreateOrGetTestCourse(user) + if err != nil { + return model.Stream{}, model.Course{}, err + } + + var stream model.Stream + err = DB.FirstOrCreate(&stream, model.Stream{ + CourseID: course.ID, + Name: "Test Stream", + Description: "This is a test stream", + LectureHallID: 0, + }).Error + if err != nil { + return model.Stream{}, model.Course{}, err + } + + stream.Start = time.Now().Add(5 * time.Minute) + stream.End = time.Now().Add(1 * time.Hour) + stream.LiveNow = true + stream.Recording = true + stream.LiveNowTimestamp = time.Now().Add(5 * time.Minute) + stream.Private = true + streamKey := uuid.NewV4().String() + stream.StreamKey = strings.ReplaceAll(streamKey, "-", "") + stream.LectureHallID = 1 + err = DB.Save(&stream).Error + if err != nil { + return model.Stream{}, model.Course{}, err + } + + return stream, course, err +} + +// Helper method to fetch test course for current user. +func (d streamsDao) CreateOrGetTestCourse(user *model.User) (model.Course, error) { + var course model.Course + err := DB.FirstOrCreate(&course, model.Course{ + Name: "(" + strconv.Itoa(int(user.ID)) + ") " + user.Name + "'s Test Course", + TeachingTerm: "Test", + Slug: "TESTCOURSE", + Year: 1234, + Visibility: "hidden", + VODEnabled: false, // TODO: Change to VODEnabled: true for default testcourse if necessary + }).Error + if err != nil { + return model.Course{}, err + } + + err = CoursesDao.AddAdminToCourse(NewDaoWrapper().CoursesDao, user.ID, course.ID) + if err != nil { + return model.Course{}, err + } + + return course, nil +} + // SetLectureHall set lecture-halls of streamIds to lectureHallID func (d streamsDao) SetLectureHall(streamIDs []uint, lectureHallID uint) error { return DB.Model(&model.Stream{}).Where("id IN ?", streamIDs).Update("lecture_hall_id", lectureHallID).Error diff --git a/dao/token.go b/dao/token.go index d91fc45b5..4cb1f0cb1 100644 --- a/dao/token.go +++ b/dao/token.go @@ -13,7 +13,8 @@ type TokenDao interface { AddToken(token model.Token) error GetToken(token string) (model.Token, error) - GetAllTokens() ([]AllTokensDto, error) + GetTokenByID(id string) (model.Token, error) + GetAllTokens(user *model.User) ([]AllTokensDto, error) TokenUsed(token model.Token) error @@ -40,11 +41,31 @@ func (d tokenDao) GetToken(token string) (model.Token, error) { return t, err } +func (d tokenDao) GetTokenByID(id string) (model.Token, error) { + var t model.Token + err := DB.Model(&t).Where("id = ?", id).First(&t).Error + return t, err +} + // GetAllTokens returns all tokens and the corresponding users name, email and lrz id -func (d tokenDao) GetAllTokens() ([]AllTokensDto, error) { +func (d tokenDao) GetAllTokens(user *model.User) ([]AllTokensDto, error) { var tokens []AllTokensDto - err := DB.Raw("SELECT tokens.*, u.name as user_name, u.email as user_email, u.lrz_id as user_lrz_id FROM tokens JOIN users u ON u.id = tokens.user_id WHERE tokens.deleted_at IS null").Scan(&tokens).Error - return tokens, err + + query := DB.Table("tokens"). + Select("tokens.*, u.name as user_name, u.email as user_email, u.lrz_id as user_lrz_id"). + Joins("JOIN users u ON u.id = tokens.user_id"). + Where("tokens.deleted_at IS NULL") + + if user.Role != model.AdminType { + query = query.Where("tokens.user_id = ?", user.ID) + } + + err := query.Scan(&tokens).Error + if err != nil { + return nil, err + } + + return tokens, nil } // TokenUsed is called when a token is used. It sets the last_used field to the current time. diff --git a/go.work.sum b/go.work.sum index 7443d3293..a86b78d80 100644 --- a/go.work.sum +++ b/go.work.sum @@ -329,6 +329,7 @@ github.com/cpuguy83/go-md2man v1.0.10 h1:BSKMNlYxDvnunlTymqtgONjNnaRV1sTpcovwwjF github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM= github.com/creack/pty v1.1.9 h1:uDmaGzcdjhF4i/plgjmEsriH11Y0o7RKapEf/LDaM3w= github.com/dchest/uniuri v1.2.0 h1:koIcOUdrTIivZgSLhHQvKgqdWZq5d7KdMEWF1Ud6+5g= +github.com/dchest/uniuri v1.2.0/go.mod h1:fSzm4SLHzNZvWLvWJew423PhAzkpNQYq+uNLq4kxhkY= github.com/dgraph-io/badger/v2 v2.2007.4 h1:TRWBQg8UrlUhaFdco01nO2uXwzKS7zd+HVdwV/GHc4o= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/djherbis/atime v1.1.0 h1:rgwVbP/5by8BvvjBNrbh64Qz33idKT3pSnMSJsxhi0g= @@ -347,7 +348,6 @@ github.com/fatih/color v1.14.1 h1:qfhVLaG5s+nCROl1zJsZRxFeYrHLqWroPOQ8BWiNb4w= github.com/fatih/color v1.14.1/go.mod h1:2oHN61fhTpgcxD3TSWCgKDiH1+x4OiDVVGH8WlgGZGg= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/flosch/pongo2/v4 v4.0.2 h1:gv+5Pe3vaSVmiJvh/BZa82b7/00YUGm0PIyVVLop0Hw= -github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1 h1:QbL/5oDUmRBzO9/Z7Seo6zf912W/a6Sr4Eu0G/3Jho0= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4 h1:WtGNWLvXpe6ZudgnXrq0barxBImvnnJoMEhXAzcbM0I= @@ -377,7 +377,6 @@ github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/google/btree v1.0.0 h1:0udJVsspx3VBr5FwtLhQQtuAsVc79tTq0ocGIPAU6qo= github.com/google/flatbuffers v2.0.8+incompatible h1:ivUb1cGomAB101ZM1T0nOiWz9pSrTMoa9+EiY7igmkM= github.com/google/flatbuffers v2.0.8+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-pkcs11 v0.2.1-0.20230907215043-c6f79328ddf9 h1:OF1IPgv+F4NmqmJ98KTjdN97Vs1JxDPB3vbmYzV2dpk= github.com/google/go-pkcs11 v0.2.1-0.20230907215043-c6f79328ddf9/go.mod h1:6eQoGcuNJpa7jnd5pMGdkSaQpNDYvPlXWMcjXXThLlY= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= @@ -594,6 +593,7 @@ github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= github.com/zenazn/goji v1.0.1 h1:4lbD8Mx2h7IvloP7r2C0D6ltZP6Ufip8Hn0wmSK5LR8= +github.com/zenazn/goji v1.0.1/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU= go.etcd.io/bbolt v1.3.7 h1:j+zJOnnEjF/kyHlDDgGnVL/AIqIJPq8UoB2GSNfkUfQ= go.etcd.io/bbolt v1.3.7/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw= @@ -636,6 +636,8 @@ golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g= golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g= +golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6 h1:QE6XYQK6naiK1EPAe1g/ILLxN5RBoH5xkJk3CqlMI/Y= golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 h1:VLliZ0d+/avPrXXH+OakdXhpJuEoBZuwh1m2j7U6Iug= golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028 h1:4+4C/Iv2U4fMZBiMCc98MG1In4gJY5YRhtpDNeDeHWs= @@ -644,6 +646,7 @@ golang.org/x/mod v0.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk= golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= @@ -651,6 +654,9 @@ golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ= golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/oauth2 v0.13.0/go.mod h1:/JMhi4ZRXAf4HG9LiNmxvk+45+96RUlVThiH8FzNBn0= golang.org/x/oauth2 v0.14.0/go.mod h1:lAtNWgaWfL4cm7j2OV8TxGi9Qb7ECORx8DktCY74OwM= golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= @@ -663,11 +669,16 @@ golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= golang.org/x/term v0.11.0 h1:F9tnn/DA/Im8nCwm+fX+1/eBwi4qFjRT++MhtVC4ZX0= golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE= golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= @@ -677,6 +688,7 @@ golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.16.0 h1:GO788SKMRunPIBCXiQyo2AaexLstOrVhuAL5YwsckQM= golang.org/x/tools v0.16.0/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= google.golang.org/api v0.122.0 h1:zDobeejm3E7pEG1mNHvdxvjs5XJoCMzyNH+CmwL94Es= diff --git a/mock_dao/streams.go b/mock_dao/streams.go index c3d7f3253..1a92479b7 100644 --- a/mock_dao/streams.go +++ b/mock_dao/streams.go @@ -243,6 +243,22 @@ func (mr *MockStreamsDaoMockRecorder) GetLiveStreamsInLectureHall(lectureHallId return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLiveStreamsInLectureHall", reflect.TypeOf((*MockStreamsDao)(nil).GetLiveStreamsInLectureHall), lectureHallId) } +// GetSoonStartingStreamInfo mocks base method. +func (m *MockStreamsDao) GetSoonStartingStreamInfo(user *model.User, slug string, year int, term string) (string, string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetSoonStartingStreamInfo", user, slug, year, term) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(string) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetSoonStartingStreamInfo indicates an expected call of GetSoonStartingStreamInfo. +func (mr *MockStreamsDaoMockRecorder) GetSoonStartingStreamInfo(user, slug, year, term interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSoonStartingStreamInfo", reflect.TypeOf((*MockStreamsDao)(nil).GetSoonStartingStreamInfo), user, slug, year, term) +} + // GetStreamByID mocks base method. func (m *MockStreamsDao) GetStreamByID(ctx context.Context, id string) (model.Stream, error) { m.ctrl.T.Helper() diff --git a/mock_dao/token.go b/mock_dao/token.go index a25fc1963..bd072f58c 100644 --- a/mock_dao/token.go +++ b/mock_dao/token.go @@ -64,18 +64,18 @@ func (mr *MockTokenDaoMockRecorder) DeleteToken(id interface{}) *gomock.Call { } // GetAllTokens mocks base method. -func (m *MockTokenDao) GetAllTokens() ([]dao.AllTokensDto, error) { +func (m *MockTokenDao) GetAllTokens(user *model.User) ([]dao.AllTokensDto, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAllTokens") + ret := m.ctrl.Call(m, "GetAllTokens", user) ret0, _ := ret[0].([]dao.AllTokensDto) ret1, _ := ret[1].(error) return ret0, ret1 } // GetAllTokens indicates an expected call of GetAllTokens. -func (mr *MockTokenDaoMockRecorder) GetAllTokens() *gomock.Call { +func (mr *MockTokenDaoMockRecorder) GetAllTokens(user interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllTokens", reflect.TypeOf((*MockTokenDao)(nil).GetAllTokens)) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllTokens", reflect.TypeOf((*MockTokenDao)(nil).GetAllTokens), user) } // GetToken mocks base method. @@ -93,6 +93,21 @@ func (mr *MockTokenDaoMockRecorder) GetToken(token interface{}) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetToken", reflect.TypeOf((*MockTokenDao)(nil).GetToken), token) } +// GetTokenByID mocks base method. +func (m *MockTokenDao) GetTokenByID(id string) (model.Token, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTokenByID", id) + ret0, _ := ret[0].(model.Token) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetTokenByID indicates an expected call of GetTokenByID. +func (mr *MockTokenDaoMockRecorder) GetTokenByID(id interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTokenByID", reflect.TypeOf((*MockTokenDao)(nil).GetTokenByID), id) +} + // TokenUsed mocks base method. func (m *MockTokenDao) TokenUsed(token model.Token) error { m.ctrl.T.Helper() diff --git a/model/token.go b/model/token.go index 2ae80c731..355aad62d 100644 --- a/model/token.go +++ b/model/token.go @@ -6,7 +6,10 @@ import ( "gorm.io/gorm" ) -const TokenScopeAdmin = "admin" +const ( + TokenScopeAdmin = "admin" + TokenScopeLecturer = "lecturer" +) // Token can be used to authenticate instead of a user account type Token struct { diff --git a/tools/config.go b/tools/config.go index 38911e29d..d439f32d4 100644 --- a/tools/config.go +++ b/tools/config.go @@ -174,6 +174,7 @@ type Config struct { VodURLTemplate string `yaml:"vodURLTemplate"` CanonicalURL string `yaml:"canonicalURL"` WikiURL string `yaml:"wikiURL"` + RtmpProxyURL string `yaml:"rtmpProxyURL"` } type MailConfig struct { diff --git a/web/admin.go b/web/admin.go index 95976d943..8ee7389b9 100644 --- a/web/admin.go +++ b/web/admin.go @@ -58,7 +58,7 @@ func (r mainRoutes) AdminPage(c *gin.Context) { notifications = found } case "token": - tokens, err = r.TokenDao.GetAllTokens() + tokens, err = r.TokenDao.GetAllTokens(tumLiveContext.User) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { logger.Error("couldn't query tokens", "err", err) c.AbortWithStatus(http.StatusInternalServerError) @@ -100,7 +100,7 @@ func (r mainRoutes) AdminPage(c *gin.Context) { Semesters: semesters, CurY: y, CurT: t, - Tokens: tokens, + Tokens: TokensData{Tokens: tokens, RtmpProxyURL: tools.Cfg.RtmpProxyURL, User: tumLiveContext.User}, InfoPages: infopages, ServerNotifications: serverNotifications, Notifications: notifications, @@ -150,6 +150,12 @@ type WorkersData struct { Token string } +type TokensData struct { + Tokens []dao.AllTokensDto + RtmpProxyURL string + User *model.User +} + func (r mainRoutes) LectureCutPage(c *gin.Context) { foundContext, exists := c.Get("TUMLiveContext") if !exists { @@ -327,7 +333,7 @@ type AdminPageData struct { CurT string EditCourseData EditCourseData ServerNotifications []model.ServerNotification - Tokens []dao.AllTokensDto + Tokens TokensData InfoPages []model.InfoPage Notifications []model.Notification } diff --git a/web/template/admin/admin.gohtml b/web/template/admin/admin.gohtml index 72a4ff16c..8019bf8f5 100755 --- a/web/template/admin/admin.gohtml +++ b/web/template/admin/admin.gohtml @@ -87,6 +87,37 @@ </ul> </li> {{end}} + {{if eq $curUser.Role 2}} + <li class="mt-8"><h5 + class="mb-3 lg:mb-3 uppercase tracking-wide font-semibold text-sm lg:text-xs text-2"> + Streaming</h5> + <ul> + <li> + <a class="px-3 py-2 transition-colors duration-200 {{if eq $page "users"}}text-1{{else}}text-5{{end}} relative block" + href="/admin/token"><span + class="rounded-md absolute inset-0 bg-cyan-50 opacity-0"></span><span + class="relative">Token Management</span> + </a> + </li> + <li> + <a class="px-3 py-2 transition-colors duration-200 {{if eq $page "users"}}text-1{{else}}text-5{{end}} relative block" + href="https://docs.live.rbg.tum.de/docs/usage/02-self-streaming/" target="_blank" rel="noopener noreferrer"> + <span class="rounded-md absolute inset-0 bg-cyan-50 opacity-0"></span> + <span class="relative">Self-Streaming Guide</span> + <svg class="inline-block ml-1 w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 9l3 3m0 0l-3 3m3-3H3m18 0a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg> + </a> + </li> + <li> + <a class="px-3 py-2 transition-colors duration-200 {{if eq $page "users"}}text-1{{else}}text-5{{end}} relative block" + href="https://docs.live.rbg.tum.de/" target="_blank" rel="noopener noreferrer"> + <span class="rounded-md absolute inset-0 bg-cyan-50 opacity-0"></span> + <span class="relative">Need Help?</span> + <svg class="inline-block ml-1 w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 9l3 3m0 0l-3 3m3-3H3m18 0a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg> + </a> + </li> + </ul> + </li> + {{end}} <li class="mt-8"><span class="mb-3 lg:mb-3 uppercase tracking-wide font-semibold text-sm lg:text-xs text-2 flex"> <span class="grow">Courses</span><a href="/admin/create-course"><i @@ -103,9 +134,16 @@ <a class="px-3 mb-3 lg:mb-3 uppercase tracking-wide font-semibold text-sm lg:text-xs transition-colors duration-200 text-5" href="#" @click="expanded=!expanded"> <i class="fas fa-chevron-right mr-1 transform" :class="expanded?'rotate-90':''"></i> + <span class="rounded-md absolute bg-cyan-50 opacity-0"></span> <span - class="rounded-md absolute bg-cyan-50 opacity-0"></span><span - class="relative">{{$semester.Year}} - {{$semester.TeachingTerm}}</span></a> + class="relative"> + {{if eq $semester.TeachingTerm "Test"}} + Test Course + {{else}} + {{$semester.Year}} - {{$semester.TeachingTerm}} + {{end}} + </span> + </a> <ul id="{{printf "semesterCourses%d%s" $semester.Year $semester.TeachingTerm}}" class="semesterCourses pl-4" x-show="expanded"> @@ -196,6 +234,38 @@ </ul> </li> {{end}} + + {{if eq $curUser.Role 2}} + <li class="mt-8"><h5 + class="mb-3 lg:mb-3 uppercase tracking-wide font-semibold text-sm lg:text-xs text-2"> + Streaming</h5> + <ul> + <li> + <a class="px-3 py-2 transition-colors duration-200 {{if eq $page "token"}}text-1{{else}}text-5{{end}} relative block" + href="/admin/token"><span + class="rounded-md absolute inset-0 bg-cyan-50 opacity-0"></span><span + class="relative">Token Management</span> + </a> + </li> + <li> + <a class="px-3 py-2 transition-colors duration-200 text-5 relative block" + href="https://docs.live.rbg.tum.de/docs/usage/02-self-streaming/" target="_blank" rel="noopener noreferrer"> + <span class="rounded-md absolute inset-0 bg-cyan-50 opacity-0"></span> + <span class="relative">Self-Streaming Guide</span> + <svg class="inline-block ml-1 w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 9l3 3m0 0l-3 3m3-3H3m18 0a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg> + </a> + </li> + <li> + <a class="px-3 py-2 transition-colors duration-200 text-5 relative block" + href="https://docs.live.rbg.tum.de/" target="_blank" rel="noopener noreferrer"> + <span class="rounded-md absolute inset-0 bg-cyan-50 opacity-0"></span> + <span class="relative">Need Help?</span> + <svg class="inline-block ml-1 w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 9l3 3m0 0l-3 3m3-3H3m18 0a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg> + </a> + </li> + </ul> + </li> + {{end}} <li class="mt-8"><span class="mb-3 lg:mb-3 uppercase tracking-wide font-semibold text-sm lg:text-xs text-2 flex"> <span class="grow">Courses</span><a title="Create Course" href="/admin/create-course"><i @@ -214,18 +284,24 @@ <i class="fas fa-chevron-right mr-1 transform" :class="expanded?'rotate-90':''"></i> <span - class="rounded-md absolute bg-cyan-50 opacity-0"></span><span - class="relative">{{$semester.Year}} - {{$semester.TeachingTerm}}</span></a> - <ul id="{{printf "semesterCourses%d%s" $semester.Year $semester.TeachingTerm}}" - class="semesterCourses pl-4" - x-show="expanded"> - {{range $course := $courses}}{{if and (eq $course.Year $semester.Year) (eq $course.TeachingTerm $semester.TeachingTerm)}} - <li> - <a class="mx-3 my-2 transition-colors duration-200 {{if eq $page "course"}} {{if eq $indexData.TUMLiveContext.Course.Model.ID $course.Model.ID}}text-1{{else}} text-5 {{end}} {{else}}text-5{{end}} relative block" - href="/admin/course/{{$course.Model.ID}}"><span - class="rounded-md absolute bg-cyan-50 opacity-0"></span><span - class="relative">{{$course.Name}}</span></a></li> - {{end}} + class="rounded-md absolute bg-cyan-50 opacity-0"></span><span + class="relative"> + {{if eq $semester.TeachingTerm "Test"}} + Test Courses + {{else}} + {{$semester.Year}} - {{$semester.TeachingTerm}} + {{end}} + </span></a> + <ul id="{{printf "semesterCourses%d%s" $semester.Year $semester.TeachingTerm}}" + class="semesterCourses pl-4" + x-show="expanded"> + {{range $course := $courses}}{{if and (eq $course.Year $semester.Year) (eq $course.TeachingTerm $semester.TeachingTerm)}} + <li> + <a class="mx-3 my-2 transition-colors duration-200 {{if eq $page "course"}} {{if eq $indexData.TUMLiveContext.Course.Model.ID $course.Model.ID}}text-1{{else}} text-5 {{end}} {{else}}text-5{{end}} relative block" + href="/admin/course/{{$course.Model.ID}}"><span + class="rounded-md absolute bg-cyan-50 opacity-0"></span><span + class="relative">{{$course.Name}}</span></a></li> + {{end}} {{end}} </ul> </li> @@ -261,8 +337,8 @@ {{template "create-course" .IndexData.VersionTag}} {{else if and (eq $curUser.Role 1) (eq .Page "courseImport")}} {{template "course-import" .}} - {{else if and (eq $curUser.Role 1) (eq .Page "token")}} - {{template "token" .Tokens}} + {{else if and (or (eq $curUser.Role 1) (eq $curUser.Role 2)) (eq .Page "token")}} + {{template "token" dict "Tokens" .Tokens "Role" $curUser.Role}} {{else if and (eq $curUser.Role 1) (eq .Page "info-pages")}} {{template "info-pages" .InfoPages}} {{else if and (eq $curUser.Role 1) (eq .Page "notifications")}} diff --git a/web/template/admin/admin_tabs/token.gohtml b/web/template/admin/admin_tabs/token.gohtml index 8ce4d64c8..9104d724d 100644 --- a/web/template/admin/admin_tabs/token.gohtml +++ b/web/template/admin/admin_tabs/token.gohtml @@ -2,26 +2,26 @@ <link rel="stylesheet" href="/static/node_modules/flatpickr/dist/flatpickr.min.css"> <script src="/static/node_modules/flatpickr/dist/flatpickr.min.js"></script> -<form class="form-container" x-data="{expires: '', scope: 'admin', generatedToken:null}" +<form class="form-container" x-data="{expires: '', scope: 'lecturer', generatedToken:null}" @submit.prevent="admin.createToken(expires, scope).then(r=>r.json()).then(r => generatedToken=r.token)"> <h1 class="form-container-title">Token Management</h1> <div class="form-container-body grid grid-cols-2 gap-3"> <table class="table-auto w-full col-span-full"> <thead> - <tr class="text-2 uppercase text-left"> - <th>User</th> + <tr class="text-2 uppercase text-center"> + <th class="px-4 text-left">User</th> <th>Scope</th> <th>Last Used</th> <th>Expires</th> <th>Actions</th> </tr> </thead> - <tbody class="text-3"> - {{range .}} - {{- /*gotype: github.com/TUM-Dev/gocast/dao.AllTokensDto*/ -}} + <tbody class="text-3 text-center"> + {{range .Tokens.Tokens}} + {{- /*gotype: github.com/TUM-Dev/gocast/web.TokensData*/ -}} <tr x-data="{id: {{.Token.Model.ID}}, show:true}" x-show="show"> - <td class="p-4">{{if .UserMail}}{{.UserMail}}{{else}}{{.UserName}} {{.UserLrzID}}{{end}}</td> + <td class="p-4 text-left">{{if .UserMail}}{{.UserMail}}{{else}}{{.UserName}} {{.UserLrzID}}{{end}}</td> <td>{{.Scope}}</td> <td>{{if .Token.LastUse.Valid}}{{.Token.LastUse.Time.Format "02 Jan 06 15:04:05"}}{{else}}never used{{end}}</td> @@ -40,17 +40,110 @@ x-init="flatpickr($el)"> </label> <select x-model="scope" class="tl-select"> - <option value="admin" class="text-4"> + <option value="lecturer" class="text-4"> + Scope: lecturer + </option> + <option value="admin" class="text-4" x-show="role == 1" x-init="role = {{.Role}}"> Scope: admin </option> </select> - <p x-show="generatedToken !== null" class="text-2"> - This is your token. Write it down and keep it safe: - <span class="font-bold" x-text="generatedToken"></span> - </p> <button type="submit" class="btn primary col-span-full"> <i class="fas fa-plus mr-1"></i>Create </button> </div> + <div x-show="generatedToken !== null" class="rounded-lg p-6 mb-4 text-1"> + <div class="flex items-center justify-between"> + <h3 class="text-lg font-semibold mb-2">Your Generated Token</h3> + <code @click="global.copyToClipboard(generatedToken)" class="block p-2 rounded-md font-mono text-sm overflow-x-auto bg-gray-200 dark:bg-secondary cursor-pointer"><span x-text="generatedToken"></span></code> + </div> + <p class="mb-4">This is your generated token. Please <span @click="global.copyToClipboard(generatedToken)" class="btn primary hover:bg-blue-700 text-white font-bold py-1 px-4 rounded cursor-pointer" x-init="generatedToken=generatedToken">Copy</span> it and store it securely. It will not be shown again.<br>To use this token for self-streaming, follow the instructions below:</p> + <p class="border-t border-gray-100 dark:border-gray-500 my-4" x-show="scope === 'lecturer'"></span> + <div class="bg-gray-100 dark:bg-gray-800 p-4 rounded-lg" x-show="scope === 'lecturer'"> + <div class="mb-4"> + <div x-data="{ tab: 'OBS' }"> + <div class="flex"> + <button type="button" @click="tab = 'OBS'" :class="tab === 'OBS' ? 'primary text-bold' : 'bg-gray-200 dark:bg-secondary'" class="px-4 py-2 rounded-t-lg">OBS</button> + <button type="button" @click="tab = 'Zoom'" :class="tab === 'Zoom' ? 'primary text-bold' : 'bg-gray-200 dark:bg-secondary'" class="px-4 py-2 rounded-t-lg">Zoom</button> + <button type="button" @click="tab = 'Teams'" :class="tab === 'Teams' ? 'primary text-bold' : 'bg-gray-200 dark:bg-secondary'" class="px-4 py-2 rounded-t-lg">Teams</button> + </div> + <div x-show="tab === 'OBS'" class="p-4 border-t border-gray-200 dark:border-gray-700"> + <ol class="list-decimal list-inside"> + <li class="mb-2">Open OBS.</li> + <li class="mb-2">Go to <strong>File</strong> > <strong>Settings</strong> > <strong>Stream</strong>.</li> + <li class="mb-2">Select the <strong>Custom</strong> service and enter the <strong>Server</strong> and <strong>Stream Key</strong> from below.</li> + <li class="mb-2">Click <strong>Start Streaming</strong> to go live.</li> + </ol> + <p class="border-t border-gray-200 dark:border-gray-500 my-4"></p> + <p class="mb-2"> + <strong>Server:</strong> + <code @click="global.copyToClipboard(`{{.Tokens.RtmpProxyURL}}`)" class="p-1 pt-2 rounded-md font-mono text-sm overflow-x-auto bg-gray-200 dark:bg-secondary cursor-pointer"> + {{.Tokens.RtmpProxyURL}} + </code> + </p> + <p> + <strong>Stream Key:</strong> + <code @click="global.copyToClipboard(generatedToken)" class="p-1 pt-2 rounded-md font-mono text-sm overflow-x-auto bg-gray-200 dark:bg-secondary cursor-pointer"> + <span x-text="generatedToken"></span> + </code> + </p> + <p class="border-t border-gray-200 dark:border-gray-500 mt-4"></p> + </div> + <div x-show="tab === 'Zoom'" class="p-4 border-t border-gray-200 dark:border-gray-700"> + <ol class="list-decimal list-inside"> + <li class="mb-2">Sign in to the Zoom web portal.</li> + <li class="mb-2">Click <strong>Meetings</strong>.</li> + <li class="mb-2">Click <strong>Schedule a Meeting</strong> and enter the required information to schedule a meeting.</li> + <li class="mb-2">Click <strong>Save</strong> to display a set of tabs with advanced options.</li> + <li class="mb-2">Click the <strong>Live Streaming</strong> tab, then click <strong>Configure Custom Streaming Service</strong>.</li> + <li class="mb-2">Follow the instructions located in the green box, which were provided by your administrator. Contact your administrator if the instructions do not include sufficient information, or enable <strong>Configure live stream during the meeting</strong> to enter the details live.</li> + <li class="mb-2">Click <strong>Save</strong> to save your livestreaming settings. The host will be able to livestream this meeting without needing to add these settings after the meeting begins.</li> + </ol> + <p class="border-t border-gray-200 dark:border-gray-500 my-4"></p> + <p class="mb-2"> + <strong>Stream URL:</strong> + <code @click="global.copyToClipboard(`{{.Tokens.RtmpProxyURL}}`)" class="p-1 pt-2 rounded-md font-mono text-sm overflow-x-auto bg-gray-200 dark:bg-secondary cursor-pointer"> + {{.Tokens.RtmpProxyURL}} + </code> + </p> + <p> + <strong>Stream Key:</strong> + <code @click="global.copyToClipboard(generatedToken)" class="p-1 pt-2 rounded-md font-mono text-sm overflow-x-auto bg-gray-200 dark:bg-secondary cursor-pointer"> + <span x-text="generatedToken"></span> + </code> + </p> + <p class="border-t border-gray-200 dark:border-gray-500 mt-4"></p> + </div> + <div x-show="tab === 'Teams'" class="p-4 border-t border-gray-200 dark:border-gray-700"> + <ol class="list-decimal list-inside"> + <li class="mb-2">Open Microsoft Teams and oin the meeting or webinar you wish to live stream.</li> + <li class="mb-2">Add the <strong>Custom Streaming</strong> app to the meeting.</li> + <li class="mb-2">Click <strong>Add</strong> and <strong>Save</strong>.</li> + <li class="mb-2">In the right-hand panel that opens, paste the <strong>Stream URL</strong> and <strong>Stream Key</strong> from below.</li> + <li class="mb-2">Click <strong>Start streaming</strong> in the lower right, then select <strong>Allow</strong> in the dialog box when it appears.</li> + <li class="mb-2">You’re now live streaming! Share your screen and/or use your cameras and microphones to run your event as you would any normal Microsoft Teams meeting.</li> + <li class="mb-2">When you’re finished with the event, you can stop streaming via Teams and YouTube.</li> + </ol> + <p class="border-t border-gray-200 dark:border-gray-500 my-4"></p> + <p class="mb-2"> + <strong>Stream URL:</strong> + <code @click="global.copyToClipboard(`{{.Tokens.RtmpProxyURL}}`)" class="p-1 pt-2 rounded-md font-mono text-sm overflow-x-auto bg-gray-200 dark:bg-secondary cursor-pointer"> + {{.Tokens.RtmpProxyURL}} + </code> + </p> + <p> + <strong>Stream Key:</strong> + <code @click="global.copyToClipboard(generatedToken)" class="p-1 pt-2 rounded-md font-mono text-sm overflow-x-auto bg-gray-200 dark:bg-secondary cursor-pointer"> + <span x-text="generatedToken"></span> + </code> + </p> + <p class="border-t border-gray-200 dark:border-gray-500 mt-4"></p> + </div> + </div> + <p class="text-sm text-5 px-4">You can start streaming from 15 minutes before the lecture starts and up to 15 minutes after the lecture ends - TUMLive automatically finds the lecture you want to stream.</p> + <p class="text-sm text-5 px-4">To test your setup, you can start streaming while not in a lecture and a private test stream will be created.</p> + </div> + <p class="mt-4 italic">For more information, please refer to the <a href="https://docs.live.rbg.tum.de/docs/usage/02-self-streaming/" target="_blank" rel="noopener noreferrer" class="text-blue-500 dark:text-blue-400 hover:text-blue-700 underline">self-streaming guide</a>.</p> + </div> + </div> </form> {{end}}