From 8b3a05917ac2e9b0d918be49bb4b346ba76a5285 Mon Sep 17 00:00:00 2001 From: Carlo Bortolan <106114526+carlobortolan@users.noreply.github.com> Date: Mon, 21 Oct 2024 13:05:07 +0200 Subject: [PATCH] Enh/rtmp proxy (#1387) * Implement method to fetch upcoming stream details and create test course * Update auth scope for old token endpoints; add endpoint to exchange personal token with stream-link * Add rtmpProxyURL to config.yaml; update token tests and dao * Update token panel to include rtmp proxy url and self-stream info * Update GetAllTokens method to filter tokens based on user role * Fix linting (golangci-lint) --- api/token.go | 108 ++++++++++++++++++- api/token_test.go | 2 + config.yaml | 1 + dao/courses.go | 2 +- dao/streams.go | 105 ++++++++++++++++++ dao/token.go | 29 ++++- go.work.sum | 16 ++- mock_dao/streams.go | 16 +++ mock_dao/token.go | 23 +++- model/token.go | 5 +- tools/config.go | 1 + web/admin.go | 12 ++- web/template/admin/admin.gohtml | 108 ++++++++++++++++--- web/template/admin/admin_tabs/token.gohtml | 117 ++++++++++++++++++--- 14 files changed, 499 insertions(+), 46 deletions(-) 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/ +// or: rtmp://proxy.example.com/?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 @@ {{end}} + {{if eq $curUser.Role 2}} +
  • + Streaming
    + +
  • + {{end}}
  • Courses + {{$semester.Year}} - {{$semester.TeachingTerm}} + class="relative"> + {{if eq $semester.TeachingTerm "Test"}} + Test Course + {{else}} + {{$semester.Year}} - {{$semester.TeachingTerm}} + {{end}} + +
      @@ -196,6 +234,38 @@
  • {{end}} + + {{if eq $curUser.Role 2}} +
  • + Streaming
    + +
  • + {{end}}
  • Courses {{$semester.Year}} - {{$semester.TeachingTerm}} -
      - {{range $course := $courses}}{{if and (eq $course.Year $semester.Year) (eq $course.TeachingTerm $semester.TeachingTerm)}} -
    • - {{$course.Name}}
    • - {{end}} + class="rounded-md absolute bg-cyan-50 opacity-0"> + {{if eq $semester.TeachingTerm "Test"}} + Test Courses + {{else}} + {{$semester.Year}} - {{$semester.TeachingTerm}} + {{end}} + +
        + {{range $course := $courses}}{{if and (eq $course.Year $semester.Year) (eq $course.TeachingTerm $semester.TeachingTerm)}} +
      • + {{$course.Name}}
      • + {{end}} {{end}}
      @@ -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 @@ -

      Token Management

      - - + + - - {{range .}} - {{- /*gotype: github.com/TUM-Dev/gocast/dao.AllTokensDto*/ -}} + + {{range .Tokens.Tokens}} + {{- /*gotype: github.com/TUM-Dev/gocast/web.TokensData*/ -}} - + @@ -40,17 +40,110 @@ x-init="flatpickr($el)"> -

      - This is your token. Write it down and keep it safe: - -

      +
      +
      +

      Your Generated Token

      + +
      +

      This is your generated token. Please Copy it and store it securely. It will not be shown again.
      To use this token for self-streaming, follow the instructions below:

      +

      +

      +
      +
      +
      + + + +
      +
      +
        +
      1. Open OBS.
      2. +
      3. Go to File > Settings > Stream.
      4. +
      5. Select the Custom service and enter the Server and Stream Key from below.
      6. +
      7. Click Start Streaming to go live.
      8. +
      +

      +

      + Server: + + {{.Tokens.RtmpProxyURL}} + +

      +

      + Stream Key: + + + +

      +

      +
      +
      +
        +
      1. Sign in to the Zoom web portal.
      2. +
      3. Click Meetings.
      4. +
      5. Click Schedule a Meeting and enter the required information to schedule a meeting.
      6. +
      7. Click Save to display a set of tabs with advanced options.
      8. +
      9. Click the Live Streaming tab, then click Configure Custom Streaming Service.
      10. +
      11. 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 Configure live stream during the meeting to enter the details live.
      12. +
      13. Click Save to save your livestreaming settings. The host will be able to livestream this meeting without needing to add these settings after the meeting begins.
      14. +
      +

      +

      + Stream URL: + + {{.Tokens.RtmpProxyURL}} + +

      +

      + Stream Key: + + + +

      +

      +
      +
      +
        +
      1. Open Microsoft Teams and oin the meeting or webinar you wish to live stream.
      2. +
      3. Add the Custom Streaming app to the meeting.
      4. +
      5. Click Add and Save.
      6. +
      7. In the right-hand panel that opens, paste the Stream URL and Stream Key from below.
      8. +
      9. Click Start streaming in the lower right, then select Allow in the dialog box when it appears.
      10. +
      11. 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.
      12. +
      13. When you’re finished with the event, you can stop streaming via Teams and YouTube.
      14. +
      +

      +

      + Stream URL: + + {{.Tokens.RtmpProxyURL}} + +

      +

      + Stream Key: + + + +

      +

      +
      +
      +

      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.

      +

      To test your setup, you can start streaming while not in a lecture and a private test stream will be created.

      +
      +

      For more information, please refer to the self-streaming guide.

      +
      +
      {{end}}
      User
      User Scope Last Used Expires Actions
      {{if .UserMail}}{{.UserMail}}{{else}}{{.UserName}} {{.UserLrzID}}{{end}}{{if .UserMail}}{{.UserMail}}{{else}}{{.UserName}} {{.UserLrzID}}{{end}} {{.Scope}} {{if .Token.LastUse.Valid}}{{.Token.LastUse.Time.Format "02 Jan 06 15:04:05"}}{{else}}never used{{end}}