diff --git a/FyneApp.toml b/FyneApp.toml index 3f6be58..e312a61 100644 --- a/FyneApp.toml +++ b/FyneApp.toml @@ -5,4 +5,4 @@ Website = "https://github.com/beebeeoii/lominus" Name = "Lominus" ID = "com.beebeeoii.lominus" Version = "1.2.0" - Build = 121 + Build = 153 diff --git a/go.mod b/go.mod index 1a27abf..edef867 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( fyne.io/fyne/v2 v2.1.1 github.com/go-co-op/gocron v1.10.0 github.com/juju/fslock v0.0.0-20160525022230-4d5c94c67b4b + github.com/sirupsen/logrus v1.8.1 github.com/sqweek/dialog v0.0.0-20211002065838-9a201b55ab91 ) @@ -19,7 +20,6 @@ require ( github.com/go-stack/stack v1.8.0 // indirect github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect github.com/robfig/cron/v3 v3.0.1 // indirect - github.com/sirupsen/logrus v1.8.1 // indirect golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect ) diff --git a/pkg/api/request.go b/pkg/api/request.go index 7d7e838..a7ba45e 100644 --- a/pkg/api/request.go +++ b/pkg/api/request.go @@ -36,6 +36,18 @@ type ModuleRequest struct { Request Request } +// MultimediaChannelRequest struct is the datapack for containing details about a specific HTTP request used for multimedia channels (Luminus Multimedia). +type MultimediaChannelRequest struct { + Module Module + Request Request +} + +// MultimediaVideoRequest struct is the datapack for containing details about a specific HTTP request used for multimedia video (Luminus Multimedia). +type MultimediaVideoRequest struct { + MultimediaChannel MultimediaChannel + Request Request +} + const ( GET_ALL_FOLDERS = 0 GET_ALL_FILES = 1 @@ -43,6 +55,9 @@ const ( ) const USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:94.0) Gecko/20100101 Firefox/94.0" +const POST = "POST" +const CONTENT_TYPE_FORM = "application/x-www-form-urlencoded" +const CONTENT_TYPE_JSON = "application/json; charset=UTF-8" // BuildModuleRequest builds and returns a ModuleRequest that can be used for Module related operations // such as retrieving all modules. @@ -80,6 +95,44 @@ func BuildGradeRequest(module Module) (GradeRequest, error) { }, nil } +// BuildMultimediaChannelRequest builds and returns a MultimediaChannelRequuest that can be used for Multimedia +// channel related operations such as retrieving all channels of a module. +// A Module is required to build a BuildMultimediaChannelRequest as it is module specific. +func BuildMultimediaChannelRequest(module Module) (MultimediaChannelRequest, error) { + jwtToken, jwtTokenErr := retrieveJwtToken() + if jwtTokenErr != nil { + return MultimediaChannelRequest{}, jwtTokenErr + } + + return MultimediaChannelRequest{ + Module: module, + Request: Request{ + Url: fmt.Sprintf(MULTIMEMDIA_CHANNEL_URL_ENDPOINT, module.Id), + JwtToken: jwtToken, + UserAgent: USER_AGENT, + }, + }, nil +} + +// BuildMultimediaChannelRequest builds and returns a MultimediaChannelRequuest that can be used for Multimedia +// channel related operations such as retrieving all channels of a module. +// A Module is required to build a BuildMultimediaChannelRequest as it is module specific. +func BuildMultimediaVideoRequest(multimediaChannel MultimediaChannel) (MultimediaVideoRequest, error) { + jwtToken, jwtTokenErr := retrieveJwtToken() + if jwtTokenErr != nil { + return MultimediaVideoRequest{}, jwtTokenErr + } + + return MultimediaVideoRequest{ + MultimediaChannel: multimediaChannel, + Request: Request{ + Url: fmt.Sprintf(LTI_DATA_URL_ENDPOINT, multimediaChannel.Id), + JwtToken: jwtToken, + UserAgent: USER_AGENT, + }, + }, nil +} + // BuildDocumentRequest builds and returns a DocumentRequest that can be used for File/Folder related operations // such as retrieving files/folders of a module. // DocumentRequests must be built using Module/Folder/File only. diff --git a/pkg/api/response.go b/pkg/api/response.go index 27192d4..6af74fa 100644 --- a/pkg/api/response.go +++ b/pkg/api/response.go @@ -23,6 +23,13 @@ type DownloadResponse struct { DownloadUrl string `json:"data"` } +// LTIDataResponse struct is the datapack for containing API response Panapto LTI data. +type LTIDataResponse struct { + DataItems []map[string]interface{} `json:"dataItems"` + Html string `json:"html"` + LaunchURL string `json:"launchURL"` +} + // GetRawResponse sends the HTTP request and marshals it into the pointer provided. // Argument provided must be a pointer. func (req Request) GetRawResponse(res interface{}) error { diff --git a/pkg/api/videos.go b/pkg/api/videos.go new file mode 100644 index 0000000..94f02dc --- /dev/null +++ b/pkg/api/videos.go @@ -0,0 +1,223 @@ +// Package api provides functions that link up and communicate with Luminus servers. +package api + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "strings" + "time" +) + +// MultimediaChannel struct is the datapack for containing details about every multimedia channel in a module. +type MultimediaChannel struct { + Id string + Name string + MediaCount int + LastUpdated int64 +} + +// MultimediaVideo struct is the datapack for containing details about every multimedia video in a module. +type MultimediaVideo struct { + Id string + Name string + FolderId string // Also known as Channel Id + FolderName string // Also known as Channel Name + M3u8Url string +} + +type panaptoCredentials struct { + aspAuth string + csrfToken string + folderId string +} + +type PanaptoVideoRawResponse struct { + D PanaptoVideoResponse `json:"d"` +} + +type PanaptoVideoResponse struct { + Results []map[string]interface{} `json:"Results"` + Total int `json:"TotalNumber"` +} + +const MULTIMEMDIA_CHANNEL_URL_ENDPOINT = "https://luminus.nus.edu.sg/v2/api/multimedia/?populate=contentSummary&ParentID=%s" +const LTI_DATA_URL_ENDPOINT = "https://luminus.nus.edu.sg/v2/api/lti/Launch/mediaweb?context_id=%s&returnURL=https://luminus.nus.edu.sg/iframe/lti-return/mediaweb" + +const PANAPTO_AUTH_URL_ENDPOINT = "https://mediaweb.ap.panopto.com/Panopto/LTI/LTI.aspx" +const PANAPTO_VIDEOS_URL_ENDPOINT = "https://mediaweb.ap.panopto.com/Panopto/Services/Data.svc/GetSessions" +const PANAPTO_VIDEO_DELIVERY_URL_ENDPOINT = "https://mediaweb.ap.panopto.com/Panopto/Pages/Viewer/DeliveryInfo.aspx" + +const PANAPTO_ASPAUTH_KEY = ".ASPXAUTH" +const PANAPTO_CSRF_KEY = "csrfToken" + +// getMultimediaChannelFieldsRequired is a helper function that returns a constant array with fields that a multimedia channel element +// returned by Luminus needs. +func getMultimediaChannelFieldsRequired() []string { + return []string{"access", "id", "name", "mediaCount", "lastUpdatedDate"} +} + +func (req MultimediaChannelRequest) GetMultimediaChannels() ([]MultimediaChannel, error) { + var multimediaChannels []MultimediaChannel + + rawResponse := RawResponse{} + err := req.Request.GetRawResponse(&rawResponse) + if err != nil { + return multimediaChannels, err + } + + for _, content := range rawResponse.Data { + if !IsResponseValid(getMultimediaChannelFieldsRequired(), content) { + continue + } + + if _, exists := content["access"]; exists { + lastUpdated := int64(-1) + lastUpdatedTime, err := time.Parse(time.RFC3339, content["lastUpdatedDate"].(string)) + if err != nil { + return multimediaChannels, err + } + lastUpdated = lastUpdatedTime.Unix() + + multimediaChannel := MultimediaChannel{ + Id: content["id"].(string), + Name: content["name"].(string), + MediaCount: int(content["mediaCount"].(float64)), + LastUpdated: lastUpdated, + } + + multimediaChannels = append(multimediaChannels, multimediaChannel) + } + } + + return multimediaChannels, nil +} + +func (req MultimediaVideoRequest) GetMultimediaVideos() ([]MultimediaVideo, error) { + panaptoAuthReqBody := url.Values{} + + var multimediaVideos []MultimediaVideo + + ltiDataResponse := LTIDataResponse{} + err := req.Request.GetRawResponse(<iDataResponse) + if err != nil { + return multimediaVideos, err + } + + for _, content := range ltiDataResponse.DataItems { + panaptoAuthReqBody.Set(content["key"].(string), content["value"].(string)) + } + + panaptoCredentials, getPanaptoCredentialsErr := getPanaptoCredentials(panaptoAuthReqBody) + if getPanaptoCredentialsErr != nil { + return multimediaVideos, err + } + + return getPanaptoVideos(panaptoCredentials) +} + +func getPanaptoCredentials(data url.Values) (panaptoCredentials, error) { + var panaptoCredentials panaptoCredentials + client := &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + + panaptoAuthReq, panaptoAuthReqErr := http.NewRequest(POST, PANAPTO_AUTH_URL_ENDPOINT, strings.NewReader(data.Encode())) + if panaptoAuthReqErr != nil { + return panaptoCredentials, panaptoAuthReqErr + } + + panaptoAuthReq.Header.Add("Content-Type", CONTENT_TYPE_FORM) + panaptoAuthReq.Header.Add("User-Agent", USER_AGENT) + + authRes, authResErr := client.Do(panaptoAuthReq) + if authResErr != nil { + return panaptoCredentials, authResErr + } + + if len(authRes.Cookies()) < 2 { + return panaptoCredentials, fmt.Errorf("unable to get Panapto credentials - invalid auth res cookies received") + } + + for _, cookie := range authRes.Cookies() { + if cookie.Name == PANAPTO_ASPAUTH_KEY { + panaptoCredentials.aspAuth = cookie.Value + } + + if cookie.Name == PANAPTO_CSRF_KEY { + panaptoCredentials.csrfToken = cookie.Value + } + } + + location := authRes.Header.Get("Location") + + indexStart := strings.Index(location, "folderID%3D") + 11 + indexEnd := strings.Index(location, "%26isLTIEmbed") + panaptoCredentials.folderId = location[indexStart:indexEnd] + + return panaptoCredentials, nil +} + +func getPanaptoVideos(credentials panaptoCredentials) ([]MultimediaVideo, error) { + var multimediaVideos []MultimediaVideo + + jsonBody := map[string]map[string]string{ + "queryParameters": { + "folderID": credentials.folderId, + }, + } + jsonValue, _ := json.Marshal(jsonBody) + client := &http.Client{} + + panaptoVideoReq, panaptoVideoReqErr := http.NewRequest(POST, PANAPTO_VIDEOS_URL_ENDPOINT, bytes.NewBuffer(jsonValue)) + if panaptoVideoReqErr != nil { + return multimediaVideos, panaptoVideoReqErr + } + + panaptoVideoReq.Header.Add("Content-Type", CONTENT_TYPE_JSON) + panaptoVideoReq.Header.Add("User-Agent", USER_AGENT) + panaptoVideoReq.AddCookie(&http.Cookie{ + Name: ".ASPXAUTH", + Value: credentials.aspAuth, + }) + panaptoVideoReq.AddCookie(&http.Cookie{ + Name: "csrfToken", + Value: credentials.csrfToken, + }) + + videoRes, videoResErr := client.Do(panaptoVideoReq) + if videoResErr != nil { + return multimediaVideos, videoResErr + } + + body, bodyErr := ioutil.ReadAll(videoRes.Body) + if bodyErr != nil { + return multimediaVideos, bodyErr + } + + var rawResponse PanaptoVideoRawResponse + json.Unmarshal(body, &rawResponse) + + for _, video := range rawResponse.D.Results { + multimediaVideo := MultimediaVideo{ + Id: video["SessionID"].(string), + Name: video["SessionName"].(string), + FolderId: video["FolderID"].(string), + FolderName: video["FolderName"].(string), + M3u8Url: video["IosVideoUrl"].(string), + } + + multimediaVideos = append(multimediaVideos, multimediaVideo) + } + + return multimediaVideos, nil +} + +// func (video MultimediaVideo) Download(dir string) error { +// m3u8. +// }