diff --git a/README.md b/README.md index 9133bbd..b302c02 100644 --- a/README.md +++ b/README.md @@ -69,22 +69,23 @@ For ease of deployment, the following `docker-compose.yml` file can be used to o ```yaml version: "3" services: - cast-fe: - image: daystram/cast:fe + cast-be: + image: daystram/cast:be ports: - - "80:80" + - "8080:8080" + - "1935:1935" + env_file: + - /path_to_env_file/.env restart: unless-stopped cast-is: # no attached GPU image: daystram/cast:is env_file: - /path_to_env_file/.env restart: unless-stopped - cast-be: - image: daystram/cast:be + cast-fe: + image: daystram/cast:fe ports: - - "8080:8080" - env_file: - - /path_to_env_file/.env + - "80:80" restart: unless-stopped mongodb: image: mongo:4.4-bionic @@ -140,5 +141,14 @@ services: This image is built on top of [NVIDIA's CUDA images](https://hub.docker.com/r/nvidia/cuda/) to enable FFmpeg harware acceleration on supported hosts. MP4Box is built from source, as seen on the [Dockerfile](https://github.com/daystram/cast/blob/master/cast-is/ingest-base.Dockerfile). +### MongoDB Indexes +For features to work properly, some indexes needs to be created in the MongoDB instance. Use the following command in `mongo` CLI to create indexes for `video` collection: + +``` +use MONGODB_NAME; +db.video.createIndex({title: "text", description: "text"}, {collation: {locale: "simple"}}); +db.video.createIndex({hash: "hashed"}); +``` + ## License This project is licensed under the [MIT License](https://github.com/daystram/cast/blob/master/LICENSE). diff --git a/cast-be/controller/middleware/auth.go b/cast-be/controller/middleware/auth.go index b23b444..3ce9d30 100644 --- a/cast-be/controller/middleware/auth.go +++ b/cast-be/controller/middleware/auth.go @@ -20,7 +20,7 @@ func AuthenticateAccessToken(ctx *context.Context) { var accessToken string if accessToken = strings.TrimPrefix(ctx.Input.Header("Authorization"), "Bearer "); accessToken == "" { if accessToken = ctx.Input.Query("access_token"); accessToken == "" { - ctx.ResponseWriter.WriteHeader(http.StatusForbidden) + ctx.ResponseWriter.WriteHeader(http.StatusUnauthorized) return } } @@ -29,7 +29,7 @@ func AuthenticateAccessToken(ctx *context.Context) { if info, err = verifyAccessToken(accessToken); err != nil || !info.Active { log.Printf("[AuthFilter] Invalid access_token. %+v\n", err) errMessage, _ := json.Marshal(map[string]interface{}{"message": "invalid access_token"}) - ctx.ResponseWriter.WriteHeader(http.StatusForbidden) + ctx.ResponseWriter.WriteHeader(http.StatusUnauthorized) _, _ = ctx.ResponseWriter.Write(errMessage) return } diff --git a/cast-be/controller/v1/video.go b/cast-be/controller/v1/video.go index 55dbf42..dab421b 100644 --- a/cast-be/controller/v1/video.go +++ b/cast-be/controller/v1/video.go @@ -129,36 +129,25 @@ func (c *VideoControllerAuth) EditVideo(_ string) datatransfers.Response { fmt.Printf("[VideoControllerAuth::EditVideo] failed parsing video details. %+v\n", err) return datatransfers.Response{Error: "Failed parsing video detail", Code: http.StatusInternalServerError} } + if matchVideo, err := c.Handler.VideoDetails(video.Hash); err != nil { + log.Printf("[VideoControllerAuth::EditVideo] failed retrieving video. %+v\n", err) + return datatransfers.Response{Error: "Failed editing video", Code: http.StatusInternalServerError} + } else if matchVideo.Title != video.Title { + if err = c.Handler.CheckUniqueVideoTitle(video.Title); err != nil { + log.Printf("[VideoControllerAuth::EditVideo] title already used. %+v\n", err) + return datatransfers.Response{Error: "Title already used", Code: http.StatusConflict} + } + } err = c.Handler.UpdateVideo(datatransfers.VideoEdit{ Hash: video.Hash, Title: video.Title, Description: video.Description, Tags: strings.Split(video.Tags, ","), - }, c.userID) + }, c.Controller, c.userID) if err != nil { fmt.Printf("[VideoControllerAuth::EditVideo] failed editing video. %+v\n", err) return datatransfers.Response{Error: "Failed editing video", Code: http.StatusInternalServerError} } - if _, _, err = c.GetFile("thumbnail"); err != nil { - if err == http.ErrMissingFile { - return datatransfers.Response{Code: http.StatusOK} - } else { - fmt.Printf("[VideoControllerAuth::EditVideo] failed retrieving profile image. %+v\n", err) - return datatransfers.Response{Error: "Failed retrieving profile image", Code: http.StatusInternalServerError} - } - } - // TODO: use S3 - //// New thumbnail uploaded - //err = c.SaveToFile("thumbnail", fmt.Sprintf("%s/thumbnail/%s.ori", config.AppConfig.UploadsDirectory, video.Hash)) - //if err != nil { - // fmt.Printf("[VideoControllerAuth::EditVideo] failed saving thumbnail. %+v\n", err) - // return datatransfers.Response{Error: "Failed saving thumbnail", Code: http.StatusInternalServerError} - //} - //err = c.Handler.NormalizeThumbnail(video.Hash) - //if err != nil { - // fmt.Printf("[VideoControllerAuth::EditVideo] failed normalizing thumbnail. %+v\n", err) - // return datatransfers.Response{Error: "Failed normalizing thumbnail", Code: http.StatusInternalServerError} - //} return datatransfers.Response{Code: http.StatusOK} } @@ -191,6 +180,11 @@ func (c *VideoControllerAuth) UploadVideo(_ string) datatransfers.Response { fmt.Printf("[VideoControllerAuth::UploadVideo] failed parsing video details. %+v\n", err) return datatransfers.Response{Error: "Failed parsing video detail", Code: http.StatusInternalServerError} } + err = c.Handler.CheckUniqueVideoTitle(upload.Title) + if err != nil { + log.Printf("[VideoControllerAuth::UploadVideo] title already used. %+v\n", err) + return datatransfers.Response{Error: "Title already used", Code: http.StatusConflict} + } var videoID primitive.ObjectID videoID, err = c.Handler.CreateVOD(datatransfers.VideoUpload{ Title: upload.Title, diff --git a/cast-be/datatransfers/chat.go b/cast-be/datatransfers/chat.go index e84ac10..f6b56ae 100644 --- a/cast-be/datatransfers/chat.go +++ b/cast-be/datatransfers/chat.go @@ -12,6 +12,7 @@ type ChatOutgoing struct { // WS notification message type NotificationOutgoing struct { Message string `json:"message"` + Name string `json:"name"` Username string `json:"username"` Hash string `json:"hash"` CreatedAt time.Time `json:"created_at"` diff --git a/cast-be/datatransfers/user.go b/cast-be/datatransfers/user.go index 879fc73..616348a 100644 --- a/cast-be/datatransfers/user.go +++ b/cast-be/datatransfers/user.go @@ -7,12 +7,14 @@ import ( type User struct { ID string `json:"id" bson:"_id"` Username string `json:"username" bson:"username"` + Name string `json:"name" bson:"name"` CreatedAt time.Time `json:"created_at,omitempty" bson:"created_at"` } type UserDetail struct { ID string `json:"id"` Username string `json:"username"` + Name string `json:"name"` Subscribers int `json:"subscribers" bson:"subscribers"` Views int `json:"views" bson:"views"` Uploads int `json:"uploads" bson:"uploads"` @@ -21,6 +23,7 @@ type UserDetail struct { type UserItem struct { ID string `json:"-" bson:"_id"` Username string `json:"username" bson:"username"` + Name string `json:"name" bson:"name"` Subscribers int `json:"subscribers" bson:"subscribers"` } diff --git a/cast-be/handlers/auth.go b/cast-be/handlers/auth.go index 426436f..523d242 100644 --- a/cast-be/handlers/auth.go +++ b/cast-be/handlers/auth.go @@ -61,6 +61,7 @@ func parseIDToken(idToken string) (user datatransfers.User, err error) { user = datatransfers.User{ ID: claims["sub"].(string), Username: claims["preferred_username"].(string), + Name: fmt.Sprintf("%s %s", claims["given_name"], claims["family_name"]), CreatedAt: time.Now(), } return diff --git a/cast-be/handlers/init.go b/cast-be/handlers/init.go index d70f7e3..0f3f29e 100644 --- a/cast-be/handlers/init.go +++ b/cast-be/handlers/init.go @@ -76,7 +76,7 @@ type Handler interface { VideoDetails(hash string) (video data.Video, err error) CreateVOD(upload data.VideoUpload, controller beego.Controller, userID string) (ID primitive.ObjectID, err error) DeleteVideo(ID primitive.ObjectID, userID string) (err error) - UpdateVideo(video data.VideoEdit, userID string) (err error) + UpdateVideo(video data.VideoEdit, controller beego.Controller, userID string) (err error) CheckUniqueVideoTitle(title string) (err error) LikeVideo(userID string, hash string, like bool) (err error) Subscribe(userID string, username string, subscribe bool) (err error) diff --git a/cast-be/handlers/rtmp.go b/cast-be/handlers/rtmp.go index a989b23..d088988 100644 --- a/cast-be/handlers/rtmp.go +++ b/cast-be/handlers/rtmp.go @@ -4,11 +4,12 @@ import ( "errors" "fmt" "io" + "log" "net/http" "path" "sync" "time" - + "github.com/nareix/joy4/av/avutil" "github.com/nareix/joy4/av/pubsub" "github.com/nareix/joy4/format/flv" @@ -64,7 +65,8 @@ func (m *module) CreateRTMPUpLink() { } fmt.Printf("[RTMPUpLink] UpLink for %s connected\n", username) m.BroadcastNotificationSubscriber(video.Author.ID, datatransfers.NotificationOutgoing{ - Message: fmt.Sprintf("%s just went live! Watch now!", video.Author.Username), + Message: fmt.Sprintf("%s just went live! Watch now!", video.Author.Name), + Name: video.Author.Name, Username: video.Author.Username, Hash: video.Hash, CreatedAt: time.Now(), @@ -86,7 +88,7 @@ func (m *module) CreateRTMPUpLink() { } m.live.uplink.Addr = fmt.Sprintf(":%d", config.AppConfig.RTMPPort) go m.live.uplink.ListenAndServe() - fmt.Printf("[CreateRTMPUpLink] RTMP UpLink Window opened at port %d\n", config.AppConfig.RTMPPort) + log.Printf("[CreateRTMPUpLink] RTMP UpLink Window opened at port %d\n", config.AppConfig.RTMPPort) } func (m *module) ControlUpLinkWindow(userID string, open bool) (err error) { diff --git a/cast-be/handlers/transcode.go b/cast-be/handlers/transcode.go index cc6aad8..66d7e3b 100644 --- a/cast-be/handlers/transcode.go +++ b/cast-be/handlers/transcode.go @@ -48,6 +48,7 @@ func (m *module) TranscodeListenerWorker() { if resolution >= 1 { m.PushNotification(video.Author.ID, datatransfers.NotificationOutgoing{ Message: fmt.Sprintf("%s is now ready in %s!", video.Title, constants.VideoResolutions[resolution]), + Name: video.Author.Name, Username: video.Author.Username, Hash: video.Hash, CreatedAt: time.Now(), @@ -55,7 +56,8 @@ func (m *module) TranscodeListenerWorker() { } if resolution == 1 { m.BroadcastNotificationSubscriber(video.Author.ID, datatransfers.NotificationOutgoing{ - Message: fmt.Sprintf("%s just uploaded %s! Watch now!", video.Author.Username, video.Title), + Message: fmt.Sprintf("%s just uploaded %s! Watch now!", video.Author.Name, video.Title), + Name: video.Author.Name, Username: video.Author.Username, Hash: video.Hash, CreatedAt: time.Now(), diff --git a/cast-be/handlers/user.go b/cast-be/handlers/user.go index 6612e4c..c8ebae5 100644 --- a/cast-be/handlers/user.go +++ b/cast-be/handlers/user.go @@ -27,6 +27,7 @@ func (m *module) UserDetails(userID string) (detail data.UserDetail, err error) detail = data.UserDetail{ ID: user.ID, Username: user.Username, + Name: user.Name, Subscribers: subscriberCount, Views: views, Uploads: len(videos), diff --git a/cast-be/handlers/video.go b/cast-be/handlers/video.go index 8c5d376..3ebdae2 100644 --- a/cast-be/handlers/video.go +++ b/cast-be/handlers/video.go @@ -4,6 +4,8 @@ import ( "bytes" "errors" "fmt" + "mime/multipart" + "net/http" "time" "github.com/astaxie/beego" @@ -66,13 +68,18 @@ func (m *module) SearchVideo(query string, _ []string, count, offset int) (video } func (m *module) VideoDetails(hash string) (video data.Video, err error) { + var author data.UserDetail var comments []data.Comment if video, err = m.db.videoOrm.GetOneByHash(hash); err != nil { return data.Video{}, errors.New(fmt.Sprintf("[VideoDetails] video with hash %s not found. %+v", hash, err)) } + if author, err = m.UserDetails(video.Author.ID); err != nil { + return data.Video{}, errors.New(fmt.Sprintf("[VideoDetails] failed video author. %+v", err)) + } if comments, err = m.db.commentOrm.GetAllByHash(hash); err != nil { return data.Video{}, errors.New(fmt.Sprintf("[VideoDetails] failed getting comment list for %s. %+v", hash, err)) } + video.Author.Subscribers = author.Subscribers video.Views++ video.Comments = comments if video.Type == constants.VideoTypeVOD { @@ -165,7 +172,7 @@ func (m *module) DeleteVideo(ID primitive.ObjectID, userID string) (err error) { return m.db.videoOrm.DeleteOneByID(ID) } -func (m *module) UpdateVideo(video data.VideoEdit, userID string) (err error) { +func (m *module) UpdateVideo(video data.VideoEdit, controller beego.Controller, userID string) (err error) { if err = m.db.videoOrm.EditVideo(data.VideoInsert{ Hash: video.Hash, Title: video.Title, @@ -175,6 +182,26 @@ func (m *module) UpdateVideo(video data.VideoEdit, userID string) (err error) { }); err != nil { return errors.New(fmt.Sprintf("[UpdateVideo] error updating video. %+v", err)) } + // Retrieve thumbnail + var thumbnail multipart.File + if thumbnail, _, err = controller.GetFile("thumbnail"); err!= nil { + if err == http.ErrMissingFile { + return nil + } else { + return fmt.Errorf("[UpdateVideo] Failed retrieving thumbnail image. %+v\n", err) + } + } + var result bytes.Buffer + if result, err = util.NormalizeImage(thumbnail, constants.ThumbnailWidth, constants.ThumbnailHeight); err != nil { + return fmt.Errorf("[UpdateVideo] Failed normalizing thumbnail image. %+v", err) + } + if _, err = m.s3.PutObject(&s3.PutObjectInput{ + Bucket: aws.String(config.AppConfig.S3Bucket), + Key: aws.String(fmt.Sprintf("%s/%s.jpg", constants.ThumbnailRootDir, video.Hash)), + Body: bytes.NewReader(result.Bytes()), + }); err != nil { + return fmt.Errorf("[UpdateVideo] Failed saving thumbnail image. %+v", err) + } return } @@ -225,7 +252,8 @@ func (m *module) Subscribe(userID string, username string, subscribe bool) (err CreatedAt: time.Now(), }) m.PushNotification(author.ID, data.NotificationOutgoing{ - Message: fmt.Sprintf("%s just subscribed!", user.Username), + Message: fmt.Sprintf("%s just subscribed!", user.Name), + Name: user.Name, Username: user.Username, CreatedAt: time.Now(), }) @@ -270,6 +298,7 @@ func (m *module) CommentVideo(userID string, hash, content string) (comment data Content: content, Author: data.UserItem{ Username: user.Username, + Name: user.Name, }, CreatedAt: now, }, nil diff --git a/cast-be/routers/router.go b/cast-be/routers/router.go index a914996..3deeaa7 100644 --- a/cast-be/routers/router.go +++ b/cast-be/routers/router.go @@ -2,7 +2,6 @@ package routers import ( "context" - "fmt" "log" "github.com/astaxie/beego" @@ -80,7 +79,7 @@ func init() { // Init Transcoder Listener go h.TranscodeListenerWorker() - fmt.Printf("[Initialization] Initialization complete!\n") + log.Printf("[Initialization] Initialization complete!\n") apiV1 := beego.NewNamespace("api/v1", beego.NSNamespace("/ping", diff --git a/cast-fe/package.json b/cast-fe/package.json index cceae6b..7e4bc98 100644 --- a/cast-fe/package.json +++ b/cast-fe/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "dependencies": { - "@daystram/ratify-client": "^1.3.0", + "@daystram/ratify-client": "^1.4.0", "axios": ">=0.21.1", "bootstrap": "^4.4.1", "bs-custom-file-input": "^1.3.4", diff --git a/cast-fe/public/index.html b/cast-fe/public/index.html index 889e6cb..927224f 100644 --- a/cast-fe/public/index.html +++ b/cast-fe/public/index.html @@ -11,7 +11,7 @@ /> - + cast diff --git a/cast-fe/src/Routes.jsx b/cast-fe/src/Routes.jsx index 5f0f80d..18ec4f6 100644 --- a/cast-fe/src/Routes.jsx +++ b/cast-fe/src/Routes.jsx @@ -7,14 +7,14 @@ import { Home, Liked, Live, + Manage, Profile, Scene, Search, Subscribed, Trending, -} from "./components"; -import Manage from "./components/Manage"; -import auth from "./helper/auth"; +} from "./views"; +import { login, logout, callback, authManager } from "./helper/auth"; const Routes = () => { return ( @@ -28,16 +28,12 @@ const Routes = () => { - {/* */} - - - - {/* */} - {/* */} - {/* */} + + + @@ -49,7 +45,7 @@ const PrivateRoute = ({ component: Component, ...rest }) => ( - auth().is_authenticated() ? ( + authManager.isAuthenticated() ? ( ) : ( ( - auth().is_authenticated() ? ( + authManager.isAuthenticated() ? ( ) : ( diff --git a/cast-fe/src/apis/api.js b/cast-fe/src/apis/api.js new file mode 100644 index 0000000..6754caf --- /dev/null +++ b/cast-fe/src/apis/api.js @@ -0,0 +1,140 @@ +import axios from "axios"; +import { ACCESS_TOKEN } from "@daystram/ratify-client"; +import { authManager, refreshAuth } from "../helper/auth"; + +const baseAPI = `${ + process.env.NODE_ENV === "development" + ? process.env.REACT_APP_DEV_BASE_API + : "" +}/api/v1`; +const baseCDN = "https://cdn.daystram.com/cast"; +const baseWS = + process.env.NODE_ENV === "development" + ? `${process.env.REACT_APP_DEV_BASE_WS}/api/v1` + : `wss://${window.location.hostname}/api/v1`; + +const apiClient = axios.create({ + baseURL: baseAPI, +}); + +apiClient.interceptors.response.use( + (response) => { + return response; + }, + (error) => { + if (error.response.status === 401) { + refreshAuth(window.location.href); + } + return Promise.reject(error); + } +); + +const withAuth = () => ({ + headers: { + Authorization: `Bearer ${authManager.getToken(ACCESS_TOKEN)}`, + }, +}); + +export default { + cdn: { + thumbnail(hash) { + return `${baseCDN}/thumbnail/${hash}.jpg`; + }, + vod(hash) { + return `${baseCDN}/video/${hash}/manifest.mpd`; + }, + download(hash) { + return `${baseCDN}/video/${hash}/video.mp4`; + }, + }, + live: { + stream(username) { + return `${baseAPI}/live/stream/${username}`; + }, + window: { + status() { + return apiClient.get(`/p/live/window`, withAuth()); + }, + set(open) { + return apiClient.put(`/p/live/window?open=${open}`, {}, withAuth()); + }, + }, + }, + ws: { + chat(hash) { + const token = authManager.getToken(ACCESS_TOKEN); + return `${baseWS}${token && "/p"}/ws/chat/${hash}${ + token && "?access_token=" + token + }`; + }, + notification() { + return `${baseWS}/p/ws/notification?access_token=${authManager.getToken( + ACCESS_TOKEN + )}`; + }, + }, + cast: { + list(params) { + return apiClient.get(`/video/list`, { params }); + }, + listCurated(params) { + return apiClient.get(`/p/video/list`, { params, ...withAuth() }); + }, + search(params) { + return apiClient.get(`/video/search`, { params }); + }, + detail(params) { + return apiClient.get(`/video/details`, { params }); + }, + upload(form, onUploadProgress) { + return apiClient.post(`/p/video/upload`, form, { + headers: { + "Access-Control-Allow-Origin": "*", + "Content-Type": "multipart/form-data", + ...withAuth().headers, + }, + onUploadProgress, + }); + }, + edit(form) { + return apiClient.put(`/p/video/edit`, form, { + headers: { + "Access-Control-Allow-Origin": "*", + "Content-Type": "multipart/form-data", + ...withAuth().headers, + }, + }); + }, + remove(hash) { + return apiClient.delete(`/p/video/delete`, { + params: { hash }, + ...withAuth(), + }); + }, + like(data) { + return apiClient.post(`/p/video/like`, data, withAuth()); + }, + comment(data) { + return apiClient.post(`/p/video/comment`, data, withAuth()); + }, + titleCheck(title) { + return apiClient.get(`/p/video/check`, { + params: { title }, + ...withAuth(), + }); + }, + }, + user: { + detail() { + return apiClient.get(`/p/user/info`, withAuth()); + }, + subscribe(data) { + return apiClient.post(`/p/video/subscribe`, data, withAuth()); + }, + }, + auth: { + register(idToken) { + return apiClient.post(`/p/auth/check`, { id_token: idToken }, withAuth()); + }, + }, +}; diff --git a/cast-fe/src/components/Author.jsx b/cast-fe/src/components/Author.jsx index 7a4f427..de22f67 100644 --- a/cast-fe/src/components/Author.jsx +++ b/cast-fe/src/components/Author.jsx @@ -1,7 +1,6 @@ import React from "react"; import { Button, Col, Image } from "react-bootstrap"; import abbreviate from "../helper/abbreviate"; -import urls from "../helper/url"; function Author(props) { function handleSubscribe(e) { @@ -12,7 +11,7 @@ function Author(props) { <>
@@ -37,13 +39,11 @@ function Cast(props) {
-

{props.video.title}

diff --git a/cast-fe/src/components/CastEditable.jsx b/cast-fe/src/components/CastEditable.jsx index 2017be9..227d990 100644 --- a/cast-fe/src/components/CastEditable.jsx +++ b/cast-fe/src/components/CastEditable.jsx @@ -12,12 +12,11 @@ import { Spinner, } from "react-bootstrap"; import Dropzone from "react-dropzone"; -import urls from "../helper/url"; +import { currentHash } from "../helper/url"; import format from "../helper/format"; -import axios from "axios"; import { Prompt, withRouter } from "react-router-dom"; import { WithContext as ReactTags } from "react-tag-input"; -import "./tags.css"; +import "../styles/tags.css"; import { THUMBNAIL_MAX_SIZE } from "../constants/file"; import { VIDEO_DESC_CHAR_LIMIT, @@ -25,6 +24,7 @@ import { VIDEO_TAG_COUNT, VIDEO_TITLE_CHAR_LIMIT, } from "../constants/video"; +import api from "../apis/api"; const resolutions = ["Processing", "240p", "360p", "480p", "720p", "1080p"]; let timeout = {}; @@ -40,7 +40,7 @@ class CastEditable extends Component { }) : [], description: this.props.video.description, - thumbnail: urls().thumbnail(this.props.video.hash), + thumbnail: api.cdn.thumbnail(this.props.video.hash), error_title: "", error_tags: "", error_description: "", @@ -218,12 +218,8 @@ class CastEditable extends Component { checkAvailability(value) { clearTimeout(timeout); timeout = setTimeout(() => { - axios - .get(urls().title_check(), { - params: { - title: value.trim(), - }, - }) + api.cast + .titleCheck(value.trim()) .then((response) => { if (response.data.code !== 200 && this.state.editing) { this.setState({ error_title: response.data.error }); @@ -259,13 +255,8 @@ class CastEditable extends Component { form.append("tags", this.state.tags.map((tag) => tag.text).join(",")); if (this.state.new_thumbnail) form.append("thumbnail", this.state.new_thumbnail); - axios - .put(urls().edit_video(), form, { - headers: { - "Access-Control-Allow-Origin": "*", - "Content-Type": "multipart/form-data", - }, - }) + api.cast + .edit(form) .then((response) => { clearTimeout(timeout); if (response.data.code === 200) { @@ -302,12 +293,8 @@ class CastEditable extends Component { deleteVideo() { this.setState({ error_delete: "", loading_delete: true }); - axios - .delete(urls().delete(), { - params: { - hash: this.props.video.hash, - }, - }) + api.cast + .remove(this.props.video.hash) .then((response) => { if (response.data.code === 200) { this.setState({ loading_delete: false, prompt: false }); @@ -331,16 +318,13 @@ class CastEditable extends Component { openVideo() { switch (this.props.video.type) { case "live": - if ( - this.props.video.is_live && - this.props.video.hash !== urls().current_hash() - ) + if (this.props.video.is_live && this.props.video.hash !== currentHash()) this.props.history.push(`/w/${this.props.video.hash}`); break; case "vod": if ( this.props.video.resolutions && - this.props.video.hash !== urls().current_hash() + this.props.video.hash !== currentHash() ) this.props.history.push(`/w/${this.props.video.hash}`); break; @@ -429,7 +413,7 @@ class CastEditable extends Component { {this.state.error_edit} )} {this.state.editing ? ( -
e.preventDefault()}> + e.preventDefault()}> {this.state.tags && Object.values(this.state.tags).map((tag) => ( - + {tag.text} ))} @@ -523,7 +507,7 @@ class CastEditable extends Component { )}
{this.state.editing ? ( - e.preventDefault()}> + e.preventDefault()}> { - this.setState({ loading: false }); - if (response.data.code === 200) { - this.setState({ valid: true }); - } else { - this.setState({ error_reset: "Password reset link invalid!" }); - } - }) - .catch((error) => { - console.log(error); - this.setState({ error_reset: "An error has occurred!" }); - this.setState({ loading: false }); - }); - } - } - - handleChange(e) { - this.setState({ error_reset: "", error_update: "" }); - this.setState({ [e.target.name]: e.target.value }); - this.validate(e.target.name, e.target.value); - } - - validate(field, value) { - switch (field) { - case "email": - if (!value.trim()) { - this.setState({ error_email: "Please enter your email" }); - return false; - } - let emailRe = /.+@.+\..+/; - if (!emailRe.test(value.trim())) { - this.setState({ error_email: "Invalid email address" }); - return false; - } - this.setState({ error_email: "" }); - return true; - case "password": - if (!value) { - this.setState({ error_password: "Please enter your password" }); - return false; - } - if (value.length < 8) { - this.setState({ - error_password: "Password must be at least 8 characters", - }); - return false; - } - this.setState({ error_password: "" }); - return true; - case "password2": - if (!value) { - this.setState({ error_password2: "Please re-enter your password" }); - return false; - } - if (value !== this.state.password) { - this.setState({ error_password2: "Passwords do not match" }); - return false; - } - this.setState({ error_password2: "" }); - return true; - default: - return false; - } - } - - sendLink(e) { - e.preventDefault(); - let ok = true; - ok &= !this.state.error_email; - if (!ok) return; - this.setState({ loading: true, success: false }); - axios - .post(urls().reset_password(), { - email: this.state.email.trim(), - }) - .then((response) => { - this.setState({ loading: false }); - switch (response.data.code) { - case 200: - this.setState({ success: true }); - break; - case 403: - this.setState({ unverified: false }); - break; - case 404: - this.setState({ error_reset: "Email not registered!" }); - break; - default: - this.setState({ error_reset: "An error has occurred!" }); - break; - } - }) - .catch((error) => { - console.log(error); - this.setState({ - error_reset: "An error has occurred!", - loading: false, - }); - }); - } - - updatePassword(e) { - e.preventDefault(); - let ok = true; - this.setState({ success: false }); - if (!this.state.attempted) { - this.setState({ attempted: true }); - ok &= this.validate("password", this.state.password); - ok &= this.validate("password2", this.state.password2); - } else { - ok &= !this.state.error_password; - ok &= !this.state.error_password2; - } - if (!ok) return; - this.setState({ loading: true }); - axios - .put(urls().update_password(), { - key: this.state.key, - password: this.state.password.trim(), - }) - .then((response) => { - this.setState({ loading: false, password: "", password2: "" }); - if (response.data.code === 200) { - this.setState({ - success: true, - password: "", - password2: "", - error_password: "", - error_password2: "", - }); - } else { - this.setState({ error_update: "An error has occurred!" }); - } - }) - .catch((error) => { - console.log(error); - this.setState({ - error_update: "An error has occurred!", - loading: false, - }); - }); - } - - render() { - let strength = zxcvbn(this.state.password).score; - return ( - -

Forget Password

- {this.state.key ? ( - <> - {this.state.error_reset && ( - - Invalid Link! -

- Your password reset link is invalid! Please request for a new - password reset link. -

-
-
- -
-
- )} - {this.state.valid && ( - <> - {this.state.success ? ( - - Welcome! -

- Your password has been successfully reset. You can now log - in with your new password! -

-
-
- -
-
- ) : ( - <> -

Enter your new password below.

- {this.state.error_update && ( - - {this.state.error_update} - - )} - - - - - - - {this.state.error_password} - - - - - - {this.state.error_password2} - - - - - - - - - )} - - )} - {!this.state.valid && this.state.loading && ( - - )} - - ) : ( - <> -

Enter your email below to get your password reset link.

- {this.state.error_reset && ( - {this.state.error_reset} - )} - {this.state.success && ( - Password reset link sent! - )} - {this.state.unverified && ( - - Email Unverified! -

- Your account email has not been verified. Please check your - email for a verification link before resetting your password. -

-
-
- -
-
- )} - - - - {this.state.error_email} - - - - - )} -
- ); - } -} - -let style = { - h1: { - fontFamily: "Comfortaa", - }, - content_container: { - maxWidth: 480, - }, - spinner: { - margin: "32px auto 64px auto", - display: "block", - }, -}; - -export default withRouter(Forget); diff --git a/cast-fe/src/components/player/HybridPlayer.jsx b/cast-fe/src/components/HybridPlayer.jsx similarity index 85% rename from cast-fe/src/components/player/HybridPlayer.jsx rename to cast-fe/src/components/HybridPlayer.jsx index 290dd12..8bc2dfc 100644 --- a/cast-fe/src/components/player/HybridPlayer.jsx +++ b/cast-fe/src/components/HybridPlayer.jsx @@ -1,16 +1,18 @@ import React from "react"; import "dashjs"; import videojs from "video.js"; +// import "videojs-contrib-dash" // must disable for quality selector to appear; +import "videojs-flvjs-es6"; import "videojs-contrib-quality-levels"; import "videojs-http-source-selector"; -import "videojs-contrib-dash"; + import "video.js/dist/video-js.css"; -import "videojs-flvjs-es6"; -import "./player.css"; +import "../styles/player.css"; class HybridPlayer extends React.Component { componentDidMount() { this.initPlayer(); + this.updatePlayer(); } componentDidUpdate(prevProps, prevState, snapshot) { @@ -23,7 +25,7 @@ class HybridPlayer extends React.Component { componentWillUnmount() { if (this.player) { console.log("[HybridPlayer] Dismount"); - this.player.dispose(); + // this.player.dispose(); // causes SourceBufferSink errors } } @@ -33,10 +35,10 @@ class HybridPlayer extends React.Component { fluid: true, responsive: true, aspectRatio: "16:9", - // liveui: true, - preload: "true", + preload: "false", controls: true, userActions: { hotkeys: true }, + // liveui: true, plugins: { httpSourceSelector: { default: "auto", @@ -45,30 +47,28 @@ class HybridPlayer extends React.Component { flvjs: { mediaDataSource: { isLive: true, - cors: false, // TODO: NOTICE! + cors: true, withCredentials: false, }, }, - // autoplay: this.props.live, - // poster: this.props.thumbnail, }; this.player = videojs(this.videoNode, options); + this.player.qualityLevels(); this.player.httpSourceSelector(); } updatePlayer() { - console.log(this.props.url); if (!this.props.url) return; this.player.pause(); + this.player.reset(); this.player.src({ src: this.props.url, type: this.props.live ? "video/x-flv" : "application/dash+xml", }); this.player.autoplay(this.props.live); if (this.props.live) this.player.play(); - // else this.player.pause(); - this.player.load(); this.player.poster(this.props.thumbnail); + this.player.load(); } render() { diff --git a/cast-fe/src/components/List.jsx b/cast-fe/src/components/List.jsx index e146fdd..9c23b52 100644 --- a/cast-fe/src/components/List.jsx +++ b/cast-fe/src/components/List.jsx @@ -2,13 +2,12 @@ import React, { Component } from "react"; import { Col, Row, Spinner } from "react-bootstrap"; import InfiniteScroll from "react-infinite-scroller"; import Cast from "./Cast"; -import axios from "axios"; -import urls from "../helper/url"; import { VIDEO_LIST_LIKED, VIDEO_LIST_PAGE_SIZE, VIDEO_LIST_SUBSCRIBED, } from "../constants/video"; +import api from "../apis/api"; class List extends Component { constructor(props) { @@ -28,38 +27,31 @@ class List extends Component { } fetchVideos() { - let target; - let config; + let request; if (this.props.search) { - target = urls().search(); - config = { - params: { - query: this.props.query.trim(), - count: VIDEO_LIST_PAGE_SIZE, - offset: VIDEO_LIST_PAGE_SIZE * this.state.page, - }, - }; + request = api.cast.search({ + query: this.props.query.trim(), + count: VIDEO_LIST_PAGE_SIZE, + offset: VIDEO_LIST_PAGE_SIZE * this.state.page, + }); } else { + const params = { + variant: this.props.variant, + count: VIDEO_LIST_PAGE_SIZE, + offset: VIDEO_LIST_PAGE_SIZE * this.state.page, + }; switch (this.props.variant) { case VIDEO_LIST_LIKED: - target = urls().list_authed(); + request = api.cast.listCurated(params); break; case VIDEO_LIST_SUBSCRIBED: - target = urls().list_authed(); + request = api.cast.listCurated(params); break; default: - target = urls().list(); + request = api.cast.list(params); } - config = { - params: { - variant: this.props.variant, - count: VIDEO_LIST_PAGE_SIZE, - offset: VIDEO_LIST_PAGE_SIZE * this.state.page, - }, - }; } - axios - .get(target, config) + request .then((response) => { if (response.data.code === 200) { if (!response.data.data) { diff --git a/cast-fe/src/components/Navigation.jsx b/cast-fe/src/components/Navigation.jsx index 6f61ef1..84fed5f 100644 --- a/cast-fe/src/components/Navigation.jsx +++ b/cast-fe/src/components/Navigation.jsx @@ -12,12 +12,11 @@ import { } from "react-bootstrap"; import MediaQuery from "react-responsive"; import logo from "./logo.svg"; -import auth, { refreshAuth } from "../helper/auth"; +import { authManager } from "../helper/auth"; import { MOBILE_BP } from "../constants/breakpoint"; import Sidebar from "./Sidebar"; import SidebarProfile from "./SidebarProfile"; -import axios from "axios"; - +import { ProfileImage } from "./index"; function Navigation() { const [query, setQuery] = useState( new URLSearchParams(useLocation().search).get("query") || "" @@ -25,37 +24,16 @@ function Navigation() { const [expanded, setExpanded] = useState(false); const inputRef = useRef(); const history = useHistory(); - const user = auth().user(); - axios.interceptors.response.use( - (response) => response, - (error) => { - if (error.response.status === 403) { - refreshAuth(); - } - } - ); - let profileButton = auth().is_authenticated() ? ( -
{ history.push("/profile"); }} - > - {user.given_name[0] + user.family_name[0]} -
+ /> ) : ( <> +
+

Trending Casts

{!this.state.loading.trending && diff --git a/cast-fe/src/components/Liked.jsx b/cast-fe/src/views/Liked.jsx similarity index 95% rename from cast-fe/src/components/Liked.jsx rename to cast-fe/src/views/Liked.jsx index 5c5a560..09959fe 100644 --- a/cast-fe/src/components/Liked.jsx +++ b/cast-fe/src/views/Liked.jsx @@ -1,10 +1,9 @@ import React, { Component } from "react"; import { Card, Col, Container, Row } from "react-bootstrap"; -import Sidebar from "./Sidebar"; import { MOBILE_BP } from "../constants/breakpoint"; import MediaQuery from "react-responsive"; -import List from "./List"; import { VIDEO_LIST_LIKED } from "../constants/video"; +import { List, Sidebar } from "../components"; class Liked extends Component { componentDidMount() { diff --git a/cast-fe/src/components/Live.jsx b/cast-fe/src/views/Live.jsx similarity index 95% rename from cast-fe/src/components/Live.jsx rename to cast-fe/src/views/Live.jsx index 6e4d1bf..8c3b936 100644 --- a/cast-fe/src/components/Live.jsx +++ b/cast-fe/src/views/Live.jsx @@ -1,10 +1,9 @@ import React, { Component } from "react"; import { Card, Col, Container, Row } from "react-bootstrap"; -import Sidebar from "./Sidebar"; import { MOBILE_BP } from "../constants/breakpoint"; import MediaQuery from "react-responsive"; -import List from "./List"; import { VIDEO_TYPE_LIVE } from "../constants/video"; +import { List, Sidebar } from "../components"; class Live extends Component { componentDidMount() { diff --git a/cast-fe/src/components/Manage.jsx b/cast-fe/src/views/Manage.jsx similarity index 95% rename from cast-fe/src/components/Manage.jsx rename to cast-fe/src/views/Manage.jsx index 8f5c395..0841357 100644 --- a/cast-fe/src/components/Manage.jsx +++ b/cast-fe/src/views/Manage.jsx @@ -10,17 +10,13 @@ import { Row, Spinner, } from "react-bootstrap"; -import axios from "axios"; import bsCustomFileInput from "bs-custom-file-input"; import { WithContext as ReactTags } from "react-tag-input"; -import SidebarProfile from "./SidebarProfile"; -import urls from "../helper/url"; -import CastEditable from "./CastEditable"; import { Prompt } from "react-router-dom"; -import "./tags.css"; -import "./file.css"; -import auth from "../helper/auth"; +import "../styles/tags.css"; +import "../styles/file.css"; +import { authManager } from "../helper/auth"; import MediaQuery from "react-responsive"; import { MOBILE_BP } from "../constants/breakpoint"; import { THUMBNAIL_MAX_SIZE, VIDEO_MAX_SIZE } from "../constants/file"; @@ -30,6 +26,8 @@ import { VIDEO_TAG_COUNT, VIDEO_TITLE_CHAR_LIMIT, } from "../constants/video"; +import api from "../apis/api"; +import { SidebarProfile, CastEditable } from "../components"; let timeout = {}; @@ -68,13 +66,11 @@ class Manage extends Component { } fetchVideos() { - axios - .get(urls().list(), { - params: { - author: auth().username(), - count: 8, - offset: 0, - }, + api.cast + .list({ + author: authManager.getUser().preferred_username, + count: 8, + offset: 0, }) .then((response) => { this.setState({ loading: false }); @@ -199,12 +195,8 @@ class Manage extends Component { checkAvailability(value) { clearTimeout(timeout); timeout = setTimeout(() => { - axios - .get(urls().title_check(), { - params: { - title: value.trim(), - }, - }) + api.cast + .titleCheck(value.trim()) .then((response) => { if (response.data.code !== 200) { this.setState({ error_title: response.data.error }); @@ -245,15 +237,9 @@ class Manage extends Component { form.append("tags", this.state.tags.map((tag) => tag.text).join(",")); form.append("thumbnail", this.state.thumbnail); form.append("video", this.state.video); - axios - .post(urls().upload(), form, { - headers: { - "Access-Control-Allow-Origin": "*", - "Content-Type": "multipart/form-data", - }, - onUploadProgress: (progress) => { - this.setState({ progress: (progress.loaded * 100) / progress.total }); - }, + api.cast + .upload(form, (progress) => { + this.setState({ progress: (progress.loaded * 100) / progress.total }); }) .then((response) => { console.log(response); @@ -310,7 +296,7 @@ class Manage extends Component {

)} - + diff --git a/cast-fe/src/components/Profile.jsx b/cast-fe/src/views/Profile.jsx similarity index 81% rename from cast-fe/src/components/Profile.jsx rename to cast-fe/src/views/Profile.jsx index 2f9f60c..982e6b1 100644 --- a/cast-fe/src/components/Profile.jsx +++ b/cast-fe/src/views/Profile.jsx @@ -1,17 +1,16 @@ import React, { Component } from "react"; import { Card, Col, Container, Row } from "react-bootstrap"; -import SidebarProfile from "./SidebarProfile"; -import axios from "axios"; -import urls from "../helper/url"; -import auth from "../helper/auth"; +import { ProfileImage, SidebarProfile } from "../components"; +import { authManager } from "../helper/auth"; import MediaQuery from "react-responsive"; import { MOBILE_BP } from "../constants/breakpoint"; +import api from "../apis/api"; class Profile extends Component { constructor(props) { super(props); this.state = { - user: auth().user(), + user: authManager.getUser(), subscribers: 0, views: 0, video_count: 0, @@ -25,10 +24,8 @@ class Profile extends Component { } fetchUser() { - axios - .get(urls().user_info(), { - headers: { Authorization: `Bearer ${auth().token()}` }, - }) + api.user + .detail() .then((response) => { this.setState({ loading_info: false }); if (response.data.code === 200) { @@ -62,24 +59,10 @@ class Profile extends Component {
-
- {this.state.user.given_name[0] + - this.state.user.family_name[0]} -
+

{this.state.user.given_name}{" "} diff --git a/cast-fe/src/components/Scene.jsx b/cast-fe/src/views/Scene.jsx similarity index 80% rename from cast-fe/src/components/Scene.jsx rename to cast-fe/src/views/Scene.jsx index 38bcb16..066442e 100644 --- a/cast-fe/src/components/Scene.jsx +++ b/cast-fe/src/views/Scene.jsx @@ -14,22 +14,19 @@ import { Row, Spinner, } from "react-bootstrap"; -import Cast from "./Cast"; -import Sidebar from "./Sidebar"; -import { HybridPlayer } from "./player"; +import { Cast, HybridPlayer, ProfileImage, Sidebar } from "../components"; import abbreviate from "../helper/abbreviate"; -import axios from "axios"; -import urls from "../helper/url"; import format from "../helper/format"; import { withRouter } from "react-router-dom"; -import auth from "../helper/auth"; +import { authManager } from "../helper/auth"; import TimeAgo from "react-timeago"; import queryString from "query-string"; import Chat from "./Chat"; import MediaQuery from "react-responsive"; import { MOBILE_BP } from "../constants/breakpoint"; import { VIDEO_TYPE_LIVE, VIDEO_TYPE_VOD } from "../constants/video"; -import logo from "./logo.svg"; +import logo from "../components/logo.svg"; +import api from "../apis/api"; class Scene extends Component { constructor(props) { @@ -89,17 +86,15 @@ class Scene extends Component { } fetchVideos(variant) { - axios - .get(urls().list(), { - params: { - variant: variant, - count: 8, - offset: 0, - }, + api.cast + .list({ + variant: variant, + count: 8, + offset: 0, }) .then((response) => { this.setState({ loading: { ...this.state.loading, [variant]: false } }); - if (response.data.code === 200) { + if (response.data.code === 200 && response.data.data) { this.setState({ [variant]: response.data.data.reduce((map, obj) => { map[obj.hash] = obj; @@ -115,12 +110,10 @@ class Scene extends Component { } fetchDetail(hash) { - axios - .get(urls().cast_details(), { - params: { - hash: hash, - username: auth().username(), - }, + api.cast + .detail({ + hash: hash, + username: authManager.getUser().preferred_username, }) .then((response) => { this.setState({ loading: { ...this.state.loading, current: false } }); @@ -145,7 +138,7 @@ class Scene extends Component { album: "cast", artwork: [ { - src: urls().thumbnail(this.state.video.hash), + src: api.cdn.thumbnail(this.state.video?.hash), sizes: "512x512", type: "image/jpg", }, @@ -164,9 +157,9 @@ class Scene extends Component { handleDownload() { if (this.state.loading.current) return; - if (auth().is_authenticated()) { + if (authManager.isAuthenticated()) { let link = document.createElement("a"); - link.href = urls().download(this.state.video.hash); + link.href = api.cdn.download(this.state.video.hash); link.download = `${this.state.video.title} by ${this.state.video.author.name} - cast`; document.body.appendChild(link); link.click(); @@ -183,9 +176,9 @@ class Scene extends Component { handleLike() { if (this.state.loading.current) return; - if (auth().is_authenticated()) { - axios - .post(urls().like(), { + if (authManager.isAuthenticated()) { + api.cast + .like({ hash: this.state.video.hash, like: !this.state.liked, }) @@ -214,7 +207,7 @@ class Scene extends Component { handleComment(e) { e.preventDefault(); if (this.state.loading.current) return; - if (auth().is_authenticated()) { + if (authManager.isAuthenticated()) { if (!this.state.comment.trim() || this.state.error_comment) { this.setState({ error_comment: "Please enter your comment" }); return; @@ -222,8 +215,8 @@ class Scene extends Component { if (this.state.loading.comment) return; this.setState({ loading: { ...this.state.loading, comment: true } }); this.setState({ error_submit: "" }); - axios - .post(urls().comment(), { + api.cast + .comment({ hash: this.state.video.hash, content: this.state.comment.trim(), }) @@ -250,9 +243,9 @@ class Scene extends Component { handleSubscribe() { if (this.state.loading.current) return; - if (auth().is_authenticated()) { - axios - .post(urls().subscribe(), { + if (authManager.isAuthenticated()) { + api.user + .subscribe({ author: this.state.video.author.username, subscribe: !this.state.subscribed, }) @@ -275,6 +268,7 @@ class Scene extends Component { } render() { + if (this.state.loading.live) return <>; return ( <> {!this.state.not_found && !this.state.offline && ( @@ -321,24 +315,20 @@ class Scene extends Component { > - {this.state.video && - this.state.video.tags && - Object.values(this.state.video.tags).map((tag) => ( + {this.state.video?.tags && + Object.values(this.state.video?.tags).map((tag) => ( {tag} @@ -348,15 +338,14 @@ class Scene extends Component { md={true} style={{ display: "flex", justifyContent: "flex-end" }} > - {this.state.video && - this.state.video.type === VIDEO_TYPE_VOD && ( - - get_app download - - )} + {this.state.video?.type === VIDEO_TYPE_VOD && ( + + get_app download + + )} thumb_up {" "} - {(this.state.video && - abbreviate().number(this.state.likes)) || - 0}{" "} - likes + {abbreviate().number(this.state.likes) || 0} likes remove_red_eye{" "} - {(this.state.video && - abbreviate().number(this.state.video.views)) || - 0}{" "} - {this.state.video && - (this.state.video.type === VIDEO_TYPE_LIVE - ? "viewers" - : "views")} + {abbreviate().number(this.state.video?.views) || 0}{" "} + {this.state.video?.type === VIDEO_TYPE_LIVE + ? "viewers" + : "views"} -

- {this.state.video && this.state.video.title} -

+

{this.state.video?.title}

- {this.state.video && - format().date(this.state.video.created_at)} + {format().date(this.state.video?.created_at)}

-
- {this.state.video.author.name[0]} -
+

- {this.state.video && this.state.video.author.name} + {this.state.video?.author.name}

- {(this.state.video && - abbreviate().number( - this.state.video.author.subscribers - )) || - 0}{" "} + {abbreviate().number( + this.state.video?.author.subscribers + ) || 0}{" "} subscriber - {this.state.video && - this.state.video.author.subscribers !== 1 && - "s"} + {this.state.video?.author.subscribers !== 1 && "s"}

@@ -449,7 +412,7 @@ class Scene extends Component {
- {this.state.video && this.state.video.description} + {this.state.video?.description}
@@ -495,25 +458,11 @@ class Scene extends Component { ...style.comment_item, }} > -
- {comment.author.name[0]} -
+
- {this.state.video && ( - - )} +
{this.state.vod && Object.values(this.state.vod).map((video) => ( @@ -696,10 +643,8 @@ class Scene extends Component { window.open( "https://twitter.com/intent/tweet?" + queryString.stringify({ - text: `Watch ${ - this.state.video && this.state.video.title - } by ${ - this.state.video && this.state.video.author.name + text: `Watch ${this.state.video?.title} by ${ + this.state.video?.author.username } at cast! ${window.location.href.split("?")[0]}`, }), "Share", @@ -729,10 +674,8 @@ class Scene extends Component { Share