├── .babelrc
├── .github
└── stale.yml
├── .gitignore
├── LICENSE.md
├── README.md
├── client
├── App.js
├── MainRouter.js
├── assets
│ └── images
│ │ └── unicornbike.jpg
├── auth
│ ├── PrivateRoute.js
│ ├── Signin.js
│ ├── api-auth.js
│ └── auth-helper.js
├── core
│ ├── Home.js
│ └── Menu.js
├── main.js
├── media
│ ├── DeleteMedia.js
│ ├── EditMedia.js
│ ├── Media.js
│ ├── MediaList.js
│ ├── MediaPlayer.js
│ ├── NewMedia.js
│ ├── PlayMedia.js
│ ├── RelatedMedia.js
│ └── api-media.js
├── routeConfig.js
├── theme.js
└── user
│ ├── DeleteUser.js
│ ├── EditProfile.js
│ ├── Profile.js
│ ├── Signup.js
│ ├── Users.js
│ └── api-user.js
├── config
└── config.js
├── nodemon.json
├── package.json
├── server
├── controllers
│ ├── auth.controller.js
│ ├── media.controller.js
│ └── user.controller.js
├── devBundle.js
├── express.js
├── helpers
│ └── dbErrorHandler.js
├── models
│ ├── media.model.js
│ └── user.model.js
├── routes
│ ├── auth.routes.js
│ ├── media.routes.js
│ └── user.routes.js
└── server.js
├── template.js
├── webpack.config.client.js
├── webpack.config.client.production.js
├── webpack.config.server.js
└── yarn.lock
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | ["@babel/preset-env",
4 | {
5 | "targets": {
6 | "node": "current"
7 | }
8 | }
9 | ],
10 | "@babel/preset-react"
11 | ],
12 | "plugins": [
13 | "react-hot-loader/babel"
14 | ]
15 | }
16 |
--------------------------------------------------------------------------------
/.github/stale.yml:
--------------------------------------------------------------------------------
1 | # Number of days of inactivity before an issue becomes stale
2 | daysUntilStale: 60
3 | # Number of days of inactivity before a stale issue is closed
4 | daysUntilClose: 7
5 | # Issues with these labels will never be considered stale
6 | exemptLabels:
7 | - pinned
8 | - security
9 | # Label to use when marking an issue as stale
10 | staleLabel: inactive
11 | # Comment to post when marking an issue as stale. Set to `false` to disable
12 | markComment: >
13 | This issue has been automatically marked as stale because it has not had
14 | recent activity. It will be closed if no further activity occurs. Thank you
15 | for your contributions.
16 | # Comment to post when closing a stale issue. Set to `false` to disable
17 | closeComment: false
18 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /node_modules/
2 | /dist/
3 | /data/
4 | npm-debug.log
5 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Shama Hoque
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # MERN Mediastream 2.0
2 | - *Looking for the first edition code? [Check here](https://github.com/shamahoque/mern-mediastream/tree/master)*
3 |
4 | A media streaming application with media upload and stream features - developed using React, Node, Express and MongoDB.
5 |
6 | 
7 |
8 | ### [Live Demo](http://mediastream2.mernbook.com/ "MERN Mediastream")
9 |
10 | #### What you need to run this code
11 | 1. Node (13.12.0)
12 | 2. NPM (6.14.4) or Yarn (1.22.4)
13 | 3. MongoDB (4.2.0)
14 |
15 | #### How to run this code
16 | 1. Clone this repository
17 | 2. Open command line in the cloned folder,
18 | - To install dependencies, run ``` npm install ``` or ``` yarn ```
19 | - To run the application for development, run ``` npm run development ``` or ``` yarn development ```
20 | 4. Open [localhost:3000](http://localhost:3000/) in the browser
21 | ----
22 | ### More applications built using this stack
23 |
24 | * [MERN Skeleton](https://github.com/shamahoque/mern-social/tree/second-edition)
25 | * [MERN Social](https://github.com/shamahoque/mern-social/tree/second-edition)
26 | * [MERN Classroom](https://github.com/shamahoque/mern-classroom)
27 | * [MERN Marketplace](https://github.com/shamahoque/mern-marketplace/tree/second-edition)
28 | * [MERN Expense Tracker](https://github.com/shamahoque/mern-expense-tracker)
29 | * [MERN VR Game](https://github.com/shamahoque/mern-vrgame/tree/second-edition)
30 |
31 | Learn more at [mernbook.com](http://www.mernbook.com/)
32 |
33 | ----
34 | ## Get the book
35 | #### [Full-Stack React Projects - Second Edition](https://www.packtpub.com/web-development/full-stack-react-projects-second-edition)
36 | *Learn MERN stack development by building modern web apps using MongoDB, Express, React, and Node.js*
37 |
38 |
39 |
40 | React combined with industry-tested, server-side technologies, such as Node, Express, and MongoDB, enables you to develop and deploy robust real-world full-stack web apps. This updated second edition focuses on the latest versions and conventions of the technologies in this stack, along with their new features such as Hooks in React and async/await in JavaScript. The book also explores advanced topics such as implementing real-time bidding, a web-based classroom app, and data visualization in an expense tracking app.
41 |
42 | Full-Stack React Projects will take you through the process of preparing the development environment for MERN stack-based web development, creating a basic skeleton app, and extending it to build six different web apps. You'll build apps for social media, classrooms, media streaming, online marketplaces with real-time bidding, and web-based games with virtual reality features. Throughout the book, you'll learn how MERN stack web development works, extend its capabilities for complex features, and gain actionable insights into creating MERN-based apps, along with exploring industry best practices to meet the ever-increasing demands of the real world.
43 |
44 | Things you'll learn in this book:
45 |
46 | - Extend a MERN-based application to build a variety of applications
47 | - Add real-time communication capabilities with Socket.IO
48 | - Implement data visualization features for React applications using Victory
49 | - Develop media streaming applications using MongoDB GridFS
50 | - Improve SEO for your MERN apps by implementing server-side rendering with data
51 | - Implement user authentication and authorization using JSON web tokens
52 | - Set up and use React 360 to develop user interfaces with VR capabilities
53 | - Make your MERN stack applications reliable and scalable with industry best practices
54 |
55 | If you feel this book is for you, get your [copy](https://www.amazon.com/dp/1839215410) today!
56 |
57 | ---
58 |
--------------------------------------------------------------------------------
/client/App.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import MainRouter from './MainRouter'
3 | import {BrowserRouter} from 'react-router-dom'
4 | import { ThemeProvider } from '@material-ui/styles'
5 | import theme from './theme'
6 | import { hot } from 'react-hot-loader'
7 |
8 | const App = () => {
9 | React.useEffect(() => {
10 | const jssStyles = document.querySelector('#jss-server-side')
11 | if (jssStyles) {
12 | jssStyles.parentNode.removeChild(jssStyles)
13 | }
14 | }, [])
15 | return (
16 |
17 |
18 |
19 |
20 |
21 | )}
22 |
23 | export default hot(module)(App)
24 |
--------------------------------------------------------------------------------
/client/MainRouter.js:
--------------------------------------------------------------------------------
1 | import React, {Component} from 'react'
2 | import {Route, Switch} from 'react-router-dom'
3 | import Home from './core/Home'
4 | import Users from './user/Users'
5 | import Signup from './user/Signup'
6 | import Signin from './auth/Signin'
7 | import EditProfile from './user/EditProfile'
8 | import Profile from './user/Profile'
9 | import PrivateRoute from './auth/PrivateRoute'
10 | import Menu from './core/Menu'
11 | import NewMedia from './media/NewMedia'
12 | import PlayMedia from './media/PlayMedia'
13 | import EditMedia from './media/EditMedia'
14 |
15 | const MainRouter = ({data}) => {
16 | return (
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | (
29 |
30 | )} />
31 |
32 |
)
33 | }
34 |
35 | export default MainRouter
36 |
--------------------------------------------------------------------------------
/client/assets/images/unicornbike.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shamahoque/mern-mediastream/bd167b515d9814c22456da0d847eca4f0d93739f/client/assets/images/unicornbike.jpg
--------------------------------------------------------------------------------
/client/auth/PrivateRoute.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { Route, Redirect } from 'react-router-dom'
3 | import auth from './auth-helper'
4 |
5 | const PrivateRoute = ({ component: Component, ...rest }) => (
6 | (
7 | auth.isAuthenticated() ? (
8 |
9 | ) : (
10 |
14 | )
15 | )}/>
16 | )
17 |
18 | export default PrivateRoute
19 |
--------------------------------------------------------------------------------
/client/auth/Signin.js:
--------------------------------------------------------------------------------
1 | import React, {useState} from 'react'
2 | import Card from '@material-ui/core/Card'
3 | import CardActions from '@material-ui/core/CardActions'
4 | import CardContent from '@material-ui/core/CardContent'
5 | import Button from '@material-ui/core/Button'
6 | import TextField from '@material-ui/core/TextField'
7 | import Typography from '@material-ui/core/Typography'
8 | import Icon from '@material-ui/core/Icon'
9 | import { makeStyles } from '@material-ui/core/styles'
10 | import auth from './../auth/auth-helper'
11 | import {Redirect} from 'react-router-dom'
12 | import {signin} from './api-auth.js'
13 |
14 | const useStyles = makeStyles(theme => ({
15 | card: {
16 | maxWidth: 600,
17 | margin: 'auto',
18 | textAlign: 'center',
19 | marginTop: theme.spacing(5),
20 | paddingBottom: theme.spacing(2)
21 | },
22 | error: {
23 | verticalAlign: 'middle'
24 | },
25 | title: {
26 | marginTop: theme.spacing(2),
27 | color: theme.palette.openTitle
28 | },
29 | textField: {
30 | marginLeft: theme.spacing(1),
31 | marginRight: theme.spacing(1),
32 | width: 300
33 | },
34 | submit: {
35 | margin: 'auto',
36 | marginBottom: theme.spacing(2)
37 | }
38 | }))
39 |
40 | export default function Signin(props) {
41 | const classes = useStyles()
42 | const [values, setValues] = useState({
43 | email: '',
44 | password: '',
45 | error: '',
46 | redirectToReferrer: false
47 | })
48 |
49 | const clickSubmit = () => {
50 | const user = {
51 | email: values.email || undefined,
52 | password: values.password || undefined
53 | }
54 |
55 | signin(user).then((data) => {
56 | if (data.error) {
57 | setValues({ ...values, error: data.error})
58 | } else {
59 | auth.authenticate(data, () => {
60 | setValues({ ...values, error: '',redirectToReferrer: true})
61 | })
62 | }
63 | })
64 | }
65 |
66 | const handleChange = name => event => {
67 | setValues({ ...values, [name]: event.target.value })
68 | }
69 |
70 | const {from} = props.location.state || {
71 | from: {
72 | pathname: '/'
73 | }
74 | }
75 | const {redirectToReferrer} = values
76 | if (redirectToReferrer) {
77 | return ()
78 | }
79 |
80 | return (
81 |
82 |
83 |
84 | Sign In
85 |
86 |
87 |
88 | {
89 | values.error && (
90 | error
91 | {values.error}
92 | )
93 | }
94 |
95 |
96 | Submit
97 |
98 |
99 | )
100 | }
101 |
--------------------------------------------------------------------------------
/client/auth/api-auth.js:
--------------------------------------------------------------------------------
1 | const signin = async (user) => {
2 | try {
3 | let response = await fetch('/auth/signin/', {
4 | method: 'POST',
5 | headers: {
6 | 'Accept': 'application/json',
7 | 'Content-Type': 'application/json'
8 | },
9 | credentials: 'include',
10 | body: JSON.stringify(user)
11 | })
12 | return await response.json()
13 | } catch(err) {
14 | console.log(err)
15 | }
16 | }
17 |
18 | const signout = async () => {
19 | try {
20 | let response = await fetch('/auth/signout/', { method: 'GET' })
21 | return await response.json()
22 | } catch(err) {
23 | console.log(err)
24 | }
25 | }
26 |
27 | export {
28 | signin,
29 | signout
30 | }
31 |
--------------------------------------------------------------------------------
/client/auth/auth-helper.js:
--------------------------------------------------------------------------------
1 | import { signout } from './api-auth.js'
2 |
3 | const auth = {
4 | isAuthenticated() {
5 | if (typeof window == "undefined")
6 | return false
7 |
8 | if (sessionStorage.getItem('jwt'))
9 | return JSON.parse(sessionStorage.getItem('jwt'))
10 | else
11 | return false
12 | },
13 | authenticate(jwt, cb) {
14 | if (typeof window !== "undefined")
15 | sessionStorage.setItem('jwt', JSON.stringify(jwt))
16 | cb()
17 | },
18 | clearJWT(cb) {
19 | if (typeof window !== "undefined")
20 | sessionStorage.removeItem('jwt')
21 | cb()
22 | //optional
23 | signout().then((data) => {
24 | document.cookie = "t=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"
25 | })
26 | }
27 | }
28 |
29 | export default auth
--------------------------------------------------------------------------------
/client/core/Home.js:
--------------------------------------------------------------------------------
1 | import React, {useState, useEffect} from 'react'
2 | import { makeStyles } from '@material-ui/core/styles'
3 | import Card from '@material-ui/core/Card'
4 | import Typography from '@material-ui/core/Typography'
5 | import MediaList from '../media/MediaList'
6 | import {listPopular} from '../media/api-media.js'
7 |
8 | const useStyles = makeStyles(theme => ({
9 | card: {
10 | margin: `${theme.spacing(5)}px 30px`
11 | },
12 | title: {
13 | padding:`${theme.spacing(3)}px ${theme.spacing(2.5)}px 0px`,
14 | color: theme.palette.text.secondary,
15 | fontSize: '1em'
16 | },
17 | media: {
18 | minHeight: 330
19 | }
20 | }))
21 |
22 | export default function Home(){
23 | const classes = useStyles()
24 | const [media, setMedia] = useState([])
25 |
26 | useEffect(() => {
27 | const abortController = new AbortController()
28 | const signal = abortController.signal
29 | listPopular(signal).then((data) => {
30 | if (data.error) {
31 | console.log(data.error)
32 | } else {
33 | setMedia(data)
34 | }
35 | })
36 | return function cleanup(){
37 | abortController.abort()
38 | }
39 | }, [])
40 | return (
41 |
42 |
43 | Popular Videos
44 |
45 |
46 |
47 | )
48 | }
49 |
--------------------------------------------------------------------------------
/client/core/Menu.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import AppBar from '@material-ui/core/AppBar'
3 | import Toolbar from '@material-ui/core/Toolbar'
4 | import Typography from '@material-ui/core/Typography'
5 | import IconButton from '@material-ui/core/IconButton'
6 | import HomeIcon from '@material-ui/icons/Home'
7 | import AddBoxIcon from '@material-ui/icons/AddBox'
8 | import Button from '@material-ui/core/Button'
9 | import auth from './../auth/auth-helper'
10 | import {Link, withRouter} from 'react-router-dom'
11 |
12 | const isActive = (history, path) => {
13 | if (history.location.pathname == path)
14 | return {color: '#f99085'}
15 | else
16 | return {color: '#efdcd5'}
17 | }
18 | const Menu = withRouter(({history}) => (
19 |
20 |
21 |
22 | MERN Mediastream
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | {
33 | !auth.isAuthenticated() && (
34 |
35 | Sign up
36 |
37 |
38 |
39 | Sign In
40 |
41 |
42 | )
43 | }
44 | {
45 | auth.isAuthenticated() && (
46 |
47 |
48 | Add Media
49 |
50 |
51 |
52 | My Profile
53 |
54 | {
55 | auth.signout(() => history.push('/'))
56 | }}>Sign out
57 | )
58 | }
59 |
60 |
61 |
62 | ))
63 |
64 | export default Menu
65 |
--------------------------------------------------------------------------------
/client/main.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { hydrate } from 'react-dom'
3 | import App from './App'
4 |
5 | hydrate( , document.getElementById('root'))
6 |
--------------------------------------------------------------------------------
/client/media/DeleteMedia.js:
--------------------------------------------------------------------------------
1 | import React, {useState} from 'react'
2 | import PropTypes from 'prop-types'
3 | import IconButton from '@material-ui/core/IconButton'
4 | import Button from '@material-ui/core/Button'
5 | import DeleteIcon from '@material-ui/icons/Delete'
6 | import Dialog from '@material-ui/core/Dialog'
7 | import DialogActions from '@material-ui/core/DialogActions'
8 | import DialogContent from '@material-ui/core/DialogContent'
9 | import DialogContentText from '@material-ui/core/DialogContentText'
10 | import DialogTitle from '@material-ui/core/DialogTitle'
11 | import auth from './../auth/auth-helper'
12 | import {remove} from './api-media.js'
13 | import {Redirect} from 'react-router-dom'
14 |
15 | export default function DeleteMedia(props) {
16 | const [open, setOpen] = useState(false)
17 | const [redirect, setRedirect] = useState(false)
18 |
19 | const jwt = auth.isAuthenticated()
20 | const clickButton = () => {
21 | setOpen(true)
22 | }
23 | const deleteMedia = () => {
24 | const jwt = auth.isAuthenticated()
25 | remove({
26 | mediaId: props.mediaId
27 | }, {t: jwt.token}).then((data) => {
28 | if (data.error) {
29 | console.log(data.error)
30 | } else {
31 | setRedirect(true)
32 | }
33 | })
34 | }
35 | const handleRequestClose = () => {
36 | setOpen(false)
37 | }
38 | if (redirect) {
39 | return
40 | }
41 | return (
42 |
43 |
44 |
45 |
46 |
47 | {"Delete "+props.mediaTitle}
48 |
49 |
50 | Confirm to delete {props.mediaTitle} from your account.
51 |
52 |
53 |
54 |
55 | Cancel
56 |
57 |
58 | Confirm
59 |
60 |
61 |
62 | )
63 | }
64 |
65 | DeleteMedia.propTypes = {
66 | mediaId: PropTypes.string.isRequired,
67 | mediaTitle: PropTypes.string.isRequired
68 | }
--------------------------------------------------------------------------------
/client/media/EditMedia.js:
--------------------------------------------------------------------------------
1 | import React, {useState, useEffect} from 'react'
2 | import Card from '@material-ui/core/Card'
3 | import CardActions from '@material-ui/core/CardActions'
4 | import CardContent from '@material-ui/core/CardContent'
5 | import Button from '@material-ui/core/Button'
6 | import TextField from '@material-ui/core/TextField'
7 | import Typography from '@material-ui/core/Typography'
8 | import Icon from '@material-ui/core/Icon'
9 | import { makeStyles } from '@material-ui/core/styles'
10 | import auth from './../auth/auth-helper'
11 | import {read, update} from './api-media.js'
12 | import {Redirect} from 'react-router-dom'
13 |
14 | const useStyles = makeStyles(theme => ({
15 | card: {
16 | maxWidth: 500,
17 | margin: 'auto',
18 | textAlign: 'center',
19 | marginTop: theme.spacing(5),
20 | paddingBottom: theme.spacing(2)
21 | },
22 | title: {
23 | margin: theme.spacing(2),
24 | color: theme.palette.protectedTitle,
25 | fontSize: '1em'
26 | },
27 | error: {
28 | verticalAlign: 'middle'
29 | },
30 | textField: {
31 | marginLeft: theme.spacing(1),
32 | marginRight: theme.spacing(1),
33 | width: 300
34 | },
35 | submit: {
36 | margin: 'auto',
37 | marginBottom: theme.spacing(2)
38 | },
39 | input: {
40 | display: 'none'
41 | },
42 | filename:{
43 | marginLeft:'10px'
44 | }
45 | }))
46 |
47 | export default function EditProfile({ match }) {
48 | const classes = useStyles()
49 | const [media, setMedia] = useState({title: '', description:'', genre:''})
50 | const [redirect, setRedirect] = useState(false)
51 | const [error, setError] = useState('')
52 | const jwt = auth.isAuthenticated()
53 |
54 | useEffect(() => {
55 | const abortController = new AbortController()
56 | const signal = abortController.signal
57 |
58 | read({mediaId: match.params.mediaId}).then((data) => {
59 | if (data.error) {
60 | setError(data.error)
61 | } else {
62 | setMedia(data)
63 | }
64 | })
65 | return function cleanup(){
66 | abortController.abort()
67 | }
68 | }, [match.params.mediaId])
69 |
70 | const clickSubmit = () => {
71 | const jwt = auth.isAuthenticated()
72 | update({
73 | mediaId: media._id
74 | }, {
75 | t: jwt.token
76 | }, media).then((data) => {
77 | if (data.error) {
78 | setError(data.error)
79 | } else {
80 | setRedirect(true)
81 | }
82 | })
83 | }
84 |
85 | const handleChange = name => event => {
86 | let updatedMedia = {...media}
87 | updatedMedia[name] = event.target.value
88 | setMedia(updatedMedia)
89 | }
90 | if (redirect) {
91 | return ( )
92 | }
93 | return (
94 |
95 |
96 |
97 | Edit Video Details
98 |
99 |
100 |
110 |
111 | {
112 | error &&
113 | (
114 | error
115 | {error}
116 | )
117 | }
118 |
119 |
120 | Submit
121 |
122 |
123 | )
124 | }
125 |
--------------------------------------------------------------------------------
/client/media/Media.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import PropTypes from 'prop-types'
3 | import { makeStyles } from '@material-ui/core/styles'
4 | import Card from '@material-ui/core/Card'
5 | import CardHeader from '@material-ui/core/CardHeader'
6 | import List from '@material-ui/core/List'
7 | import ListItem from '@material-ui/core/ListItem'
8 | import ListItemAvatar from '@material-ui/core/ListItemAvatar'
9 | import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction'
10 | import ListItemText from '@material-ui/core/ListItemText'
11 | import IconButton from '@material-ui/core/IconButton'
12 | import Edit from '@material-ui/icons/Edit'
13 | import Avatar from '@material-ui/core/Avatar'
14 | import auth from './../auth/auth-helper'
15 | import {Link} from 'react-router-dom'
16 | import Divider from '@material-ui/core/Divider'
17 | import DeleteMedia from './DeleteMedia'
18 | import MediaPlayer from './MediaPlayer'
19 |
20 | const useStyles = makeStyles(theme => ({
21 | card: {
22 | padding:'20px'
23 | },
24 | header: {
25 | padding:'0px 16px 16px 12px'
26 | },
27 | action: {
28 | margin: '24px 20px 0px 0px',
29 | display: 'inline-block',
30 | fontSize: '1.15em',
31 | color: theme.palette.secondary.dark
32 | },
33 | avatar: {
34 | color: theme.palette.primary.contrastText,
35 | backgroundColor: theme.palette.primary.light
36 | }
37 | }))
38 |
39 | export default function Media(props) {
40 | const classes = useStyles()
41 | const mediaUrl = props.media._id
42 | ? `/api/media/video/${props.media._id}`
43 | : null
44 | const nextUrl = props.nextUrl
45 | return (
46 |
47 | {props.media.views + ' views'}
51 | }
52 | subheader={props.media.genre}
53 | />
54 |
55 |
56 |
57 |
58 |
59 | {props.media.postedBy.name && props.media.postedBy.name[0]}
60 |
61 |
62 |
64 | { auth.isAuthenticated().user
65 | && auth.isAuthenticated().user._id == props.media.postedBy._id
66 | && (
67 |
68 |
69 |
70 |
71 |
72 |
73 | )
74 | }
75 |
76 |
77 |
78 |
79 |
80 |
81 | )
82 | }
83 |
84 |
85 | Media.propTypes = {
86 | media: PropTypes.object,
87 | nextUrl: PropTypes.string,
88 | handleAutoplay: PropTypes.func.isRequired
89 | }
90 |
91 |
--------------------------------------------------------------------------------
/client/media/MediaList.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import PropTypes from 'prop-types'
3 | import { makeStyles } from '@material-ui/core/styles'
4 | import GridList from '@material-ui/core/GridList'
5 | import GridListTileBar from '@material-ui/core/GridListTileBar'
6 | import GridListTile from '@material-ui/core/GridListTile'
7 | import {Link} from 'react-router-dom'
8 | import ReactPlayer from 'react-player'
9 |
10 | const useStyles = makeStyles(theme => ({
11 | root: {
12 | display: 'flex',
13 | flexWrap: 'wrap',
14 | justifyContent: 'space-around',
15 | overflow: 'hidden',
16 | background: theme.palette.background.paper,
17 | textAlign: 'left',
18 | padding: '8px 16px'
19 | },
20 | gridList: {
21 | width: '100%',
22 | minHeight: 180,
23 | padding: '0px 0 10px'
24 | },
25 | title: {
26 | padding:`${theme.spacing(3)}px ${theme.spacing(2.5)}px ${theme.spacing(2)}px`,
27 | color: theme.palette.openTitle,
28 | width: '100%'
29 | },
30 | tile: {
31 | textAlign: 'center',
32 | maxHeight: '100%'
33 | },
34 | tileBar: {
35 | backgroundColor: 'rgba(0, 0, 0, 0.72)',
36 | textAlign: 'left',
37 | height: '55px'
38 | },
39 | tileTitle: {
40 | fontSize:'1.1em',
41 | marginBottom:'5px',
42 | color:'rgb(193, 173, 144)',
43 | display:"block"
44 | },
45 | tileGenre: {
46 | float: 'right',
47 | color:'rgb(193, 182, 164)',
48 | marginRight: '8px'
49 | }
50 | }))
51 |
52 | export default function MediaList(props) {
53 | const classes = useStyles()
54 | return (
55 |
56 |
57 | {props.media.map((tile, i) => (
58 |
59 |
60 |
61 |
62 | {tile.title} }
64 | subtitle={
65 | {tile.views} views
66 |
67 | {tile.genre}
68 |
69 | }
70 | />
71 |
72 | ))}
73 |
74 |
)
75 | }
76 |
77 | MediaList.propTypes = {
78 | media: PropTypes.array.isRequired
79 | }
80 |
81 |
--------------------------------------------------------------------------------
/client/media/MediaPlayer.js:
--------------------------------------------------------------------------------
1 | import React, {useState, useEffect, useRef} from 'react'
2 | import { findDOMNode } from 'react-dom'
3 | import screenfull from 'screenfull'
4 | import IconButton from '@material-ui/core/IconButton'
5 | import Icon from '@material-ui/core/Icon'
6 | import PropTypes from 'prop-types'
7 | import {makeStyles} from '@material-ui/core/styles'
8 | import { Link } from 'react-router-dom'
9 | import ReactPlayer from 'react-player'
10 | import LinearProgress from '@material-ui/core/LinearProgress'
11 |
12 | const useStyles = makeStyles(theme => ({
13 | flex:{
14 | display:'flex'
15 | },
16 | primaryDashed: {
17 | background: 'none',
18 | backgroundColor: theme.palette.secondary.main
19 | },
20 | primaryColor: {
21 | backgroundColor: '#6969694f'
22 | },
23 | dashed: {
24 | animation: 'none'
25 | },
26 | controls:{
27 | position: 'relative',
28 | backgroundColor: '#ababab52'
29 | },
30 | rangeRoot: {
31 | position: 'absolute',
32 | width: '100%',
33 | top: '-7px',
34 | zIndex: '3456',
35 | '-webkit-appearance': 'none',
36 | backgroundColor: 'rgba(0,0,0,0)'
37 | },
38 | videoError: {
39 | width: '100%',
40 | textAlign: 'center',
41 | color: theme.palette.primary.light
42 | }
43 | }))
44 |
45 | export default function MediaPlayer(props) {
46 | const classes = useStyles()
47 | const [playing, setPlaying] = useState(false)
48 | const [volume, setVolume] = useState(0.8)
49 | const [muted, setMuted] = useState(false)
50 | const [duration, setDuration] = useState(0)
51 | const [seeking, setSeeking] = useState(false)
52 | const [playbackRate, setPlaybackRate] = useState(1.0)
53 | const [loop, setLoop] = useState(false)
54 | const [fullscreen, setFullscreen] = useState(false)
55 | const [videoError, setVideoError] = useState(false)
56 | let playerRef = useRef(null)
57 | const [values, setValues] = useState({
58 | played: 0, loaded: 0, ended: false
59 | })
60 |
61 | useEffect(() => {
62 | if (screenfull.enabled) {
63 | screenfull.on('change', () => {
64 | let fullscreen = screenfull.isFullscreen ? true : false
65 | setFullscreen(fullscreen)
66 | })
67 | }
68 | }, [])
69 | useEffect(() => {
70 | setVideoError(false)
71 | }, [props.srcUrl])
72 | const changeVolume = e => {
73 | setVolume(parseFloat(e.target.value))
74 | }
75 | const toggleMuted = () => {
76 | setMuted(!muted)
77 | }
78 | const playPause = () => {
79 | setPlaying(!playing)
80 | }
81 | const onLoop = () => {
82 | setLoop(!loop)
83 | }
84 | const onProgress = progress => {
85 | // We only want to update time slider if we are not currently seeking
86 | if (!seeking) {
87 | setValues({...values, played:progress.played, loaded: progress.loaded})
88 | }
89 | }
90 | const onClickFullscreen = () => {
91 | screenfull.request(findDOMNode(playerRef))
92 | }
93 | const onEnded = () => {
94 | if(loop){
95 | setPlaying(true)
96 | } else{
97 | props.handleAutoplay(()=>{
98 | setValues({...values, ended:true})
99 | setPlaying(false)
100 | })
101 | }
102 | }
103 | const onDuration = (duration) => {
104 | setDuration(duration)
105 | }
106 | const onSeekMouseDown = e => {
107 | setSeeking(true)
108 | }
109 | const onSeekChange = e => {
110 | setValues({...values, played:parseFloat(e.target.value), ended: parseFloat(e.target.value) >= 1})
111 | }
112 | const onSeekMouseUp = e => {
113 | setSeeking(false)
114 | playerRef.seekTo(parseFloat(e.target.value))
115 | }
116 | const ref = player => {
117 | playerRef = player
118 | }
119 | const format = (seconds) => {
120 | const date = new Date(seconds * 1000)
121 | const hh = date.getUTCHours()
122 | let mm = date.getUTCMinutes()
123 | const ss = ('0' + date.getUTCSeconds()).slice(-2)
124 | if (hh) {
125 | mm = ('0' + date.getUTCMinutes()).slice(-2)
126 | return `${hh}:${mm}:${ss}`
127 | }
128 | return `${mm}:${ss}`
129 | }
130 | const showVideoError = e => {
131 | console.log(e)
132 | setVideoError(true)
133 | }
134 |
135 | return (
136 | {videoError &&
Video Error. Try again later.
}
137 |
138 |
139 |
155 |
156 |
157 |
158 |
163 |
169 |
170 |
171 | {playing ? 'pause': (values.ended ? 'replay' : 'play_arrow')}
172 |
173 |
174 |
175 | skip_next
176 |
177 |
178 |
179 | {volume > 0 && !muted && 'volume_up' || muted && 'volume_off' || volume==0 && 'volume_mute'}
180 |
181 |
182 |
183 | loop
184 |
185 |
186 | fullscreen
187 |
188 |
189 |
190 | {format(duration * values.played)}
191 | /
192 | {format(duration)}
193 |
194 |
195 |
196 |
197 | )
198 | }
199 |
200 | MediaPlayer.propTypes = {
201 | srcUrl: PropTypes.string,
202 | nextUrl: PropTypes.string,
203 | handleAutoplay: PropTypes.func.isRequired
204 | }
205 |
--------------------------------------------------------------------------------
/client/media/NewMedia.js:
--------------------------------------------------------------------------------
1 | import React, {useState} from 'react'
2 | import auth from './../auth/auth-helper'
3 | import Card from '@material-ui/core/Card'
4 | import CardActions from '@material-ui/core/CardActions'
5 | import CardContent from '@material-ui/core/CardContent'
6 | import Button from '@material-ui/core/Button'
7 | import TextField from '@material-ui/core/TextField'
8 | import Typography from '@material-ui/core/Typography'
9 | import FileUpload from '@material-ui/icons/AddToQueue'
10 | import Icon from '@material-ui/core/Icon'
11 | import {makeStyles} from '@material-ui/core/styles'
12 | import {create} from './api-media.js'
13 | import {Redirect} from 'react-router-dom'
14 |
15 | const useStyles = makeStyles(theme => ({
16 | card: {
17 | maxWidth: 500,
18 | margin: 'auto',
19 | textAlign: 'center',
20 | marginTop: theme.spacing(5),
21 | paddingBottom: theme.spacing(2)
22 | },
23 | title: {
24 | margin: theme.spacing(2),
25 | color: theme.palette.protectedTitle,
26 | fontSize: '1em'
27 | },
28 | error: {
29 | verticalAlign: 'middle'
30 | },
31 | textField: {
32 | marginLeft: theme.spacing(1),
33 | marginRight: theme.spacing(1),
34 | width: 300
35 | },
36 | submit: {
37 | margin: 'auto',
38 | marginBottom: theme.spacing(2)
39 | },
40 | input: {
41 | display: 'none'
42 | },
43 | filename:{
44 | marginLeft:'10px'
45 | }
46 | }))
47 |
48 | export default function NewMedia(){
49 | const classes = useStyles()
50 | const [values, setValues] = useState({
51 | title: '',
52 | video: '',
53 | description: '',
54 | genre: '',
55 | redirect: false,
56 | error: '',
57 | mediaId: ''
58 | })
59 | const jwt = auth.isAuthenticated()
60 |
61 | const clickSubmit = () => {
62 | let mediaData = new FormData()
63 | values.title && mediaData.append('title', values.title)
64 | values.video && mediaData.append('video', values.video)
65 | values.description && mediaData.append('description', values.description)
66 | values.genre && mediaData.append('genre', values.genre)
67 | create({
68 | userId: jwt.user._id
69 | }, {
70 | t: jwt.token
71 | }, mediaData).then((data) => {
72 | if (data.error) {
73 | setValues({...values, error: data.error})
74 | } else {
75 | setValues({...values, error: '', mediaId: data._id, redirect: true})
76 | }
77 | })
78 | }
79 |
80 | const handleChange = name => event => {
81 | const value = name === 'video'
82 | ? event.target.files[0]
83 | : event.target.value
84 | setValues({...values, [name]: value })
85 | }
86 |
87 | if (values.redirect) {
88 | return ( )
89 | }
90 | return (
91 |
92 |
93 |
94 | New Video
95 |
96 |
97 |
98 |
99 | Upload
100 |
101 |
102 | {values.video ? values.video.name : ''}
103 |
104 |
114 |
115 | {
116 | values.error && (
117 | error
118 | {values.error}
119 | )
120 | }
121 |
122 |
123 | Submit
124 |
125 |
126 | )
127 | }
128 |
129 |
130 |
131 |
--------------------------------------------------------------------------------
/client/media/PlayMedia.js:
--------------------------------------------------------------------------------
1 | import React, {useState, useEffect} from 'react'
2 | import PropTypes from 'prop-types'
3 | import {makeStyles} from '@material-ui/core/styles'
4 | import Grid from '@material-ui/core/Grid'
5 | import {read, listRelated} from './api-media.js'
6 | import Media from './Media'
7 | import RelatedMedia from './RelatedMedia'
8 | import FormControlLabel from '@material-ui/core/FormControlLabel'
9 | import Switch from '@material-ui/core/Switch'
10 |
11 | const useStyles = makeStyles(theme => ({
12 | root: {
13 | flexGrow: 1,
14 | margin: 30,
15 | },
16 | toggle: {
17 | float: 'right',
18 | marginRight: '30px',
19 | marginTop:' 10px'
20 | }
21 | }))
22 |
23 | export default function PlayMedia(props) {
24 | const classes = useStyles()
25 | let [media, setMedia] = useState({postedBy: {}})
26 | let [relatedMedia, setRelatedMedia] = useState([])
27 | const [autoPlay, setAutoPlay] = useState(false)
28 |
29 | useEffect(() => {
30 | const abortController = new AbortController()
31 | const signal = abortController.signal
32 |
33 | read({mediaId: props.match.params.mediaId}, signal).then((data) => {
34 | if (data && data.error) {
35 | console.log(data.error)
36 | } else {
37 | setMedia(data)
38 | }
39 | })
40 | return function cleanup(){
41 | abortController.abort()
42 | }
43 | }, [props.match.params.mediaId])
44 |
45 | useEffect(() => {
46 | const abortController = new AbortController()
47 | const signal = abortController.signal
48 |
49 | listRelated({
50 | mediaId: props.match.params.mediaId}, signal).then((data) => {
51 | if (data.error) {
52 | console.log(data.error)
53 | } else {
54 | setRelatedMedia(data)
55 | }
56 | })
57 | return function cleanup(){
58 | abortController.abort()
59 | }
60 | }, [props.match.params.mediaId])
61 |
62 | const handleChange = (event) => {
63 | setAutoPlay(event.target.checked)
64 | }
65 | const handleAutoplay = (updateMediaControls) => {
66 | let playList = relatedMedia
67 | let playMedia = playList[0]
68 | if(!autoPlay || playList.length == 0 )
69 | return updateMediaControls()
70 |
71 | if(playList.length > 1){
72 | playList.shift()
73 | setMedia(playMedia)
74 | setRelatedMedia(playList)
75 | }else{
76 | listRelated({
77 | mediaId: playMedia._id}).then((data) => {
78 | if (data.error) {
79 | console.log(data.error)
80 | } else {
81 | setMedia(playMedia)
82 | setRelatedMedia(data)
83 | }
84 | })
85 | }
86 | }
87 | //render SSR data
88 | if (props.data && props.data[0] != null) {
89 | media = props.data[0]
90 | relatedMedia = []
91 | }
92 |
93 | const nextUrl = relatedMedia.length > 0
94 | ? `/media/${relatedMedia[0]._id}` : ''
95 | return (
96 |
97 |
98 |
99 |
100 |
101 | {relatedMedia.length > 0
102 | && (
103 |
110 | }
111 | label={autoPlay ? 'Autoplay ON':'Autoplay OFF'}
112 | />
113 |
114 | )
115 | }
116 |
117 |
)
118 | }
119 |
120 |
--------------------------------------------------------------------------------
/client/media/RelatedMedia.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import PropTypes from 'prop-types'
3 | import {makeStyles} from '@material-ui/core/styles'
4 | import Paper from '@material-ui/core/Paper'
5 | import Typography from '@material-ui/core/Typography'
6 | import {Link} from 'react-router-dom'
7 | import Divider from '@material-ui/core/Divider'
8 | import Card from '@material-ui/core/Card'
9 | import CardContent from '@material-ui/core/CardContent'
10 | import ReactPlayer from 'react-player'
11 |
12 | const useStyles = makeStyles(theme => ({
13 | root: theme.mixins.gutters({
14 | paddingBottom: 24,
15 | backgroundColor: '#80808024'
16 | }),
17 | title: {
18 | margin: `${theme.spacing(3)}px ${theme.spacing(1)}px ${theme.spacing(2)}px`,
19 | color: theme.palette.openTitle,
20 | fontSize: '1em'
21 | },
22 | card: {
23 | width: '100%',
24 | display: 'inline-flex'
25 | },
26 | details: {
27 | display: 'inline-block',
28 | width: "100%"
29 | },
30 | content: {
31 | flex: '1 0 auto',
32 | padding: '16px 8px 0px'
33 | },
34 | controls: {
35 | marginTop: '8px'
36 | },
37 | date: {
38 | color: 'rgba(0, 0, 0, 0.4)'
39 | },
40 | mediaTitle: {
41 | whiteSpace: 'nowrap',
42 | overflow: 'hidden',
43 | textOverflow: 'ellipsis',
44 | width: '130px',
45 | fontSize: '1em',
46 | marginBottom: '5px'
47 | },
48 | subheading: {
49 | color: 'rgba(88, 114, 128, 0.67)'
50 | },
51 | views: {
52 | display: 'inline',
53 | lineHeight: '3',
54 | paddingLeft: '8px',
55 | color: theme.palette.text.secondary
56 | }
57 | }))
58 | export default function RelatedMedia(props) {
59 | const classes = useStyles()
60 | return (
61 |
62 |
63 | Up Next
64 |
65 | {props.media.map((item, i) => {
66 | return
67 |
68 |
69 |
70 |
71 |
72 | {item.title}
73 |
74 | {item.genre}
75 |
76 |
77 |
78 | {(new Date(item.created)).toDateString()}
79 |
80 |
81 |
82 |
83 | {item.views} views
84 |
85 |
86 |
87 |
88 |
89 |
90 | })
91 | }
92 |
93 | )
94 | }
95 |
96 | RelatedMedia.propTypes = {
97 | media: PropTypes.array.isRequired
98 | }
--------------------------------------------------------------------------------
/client/media/api-media.js:
--------------------------------------------------------------------------------
1 | import config from '../../config/config'
2 | const create = async (params, credentials, media) => {
3 | try {
4 | let response = await fetch('/api/media/new/'+ params.userId, {
5 | method: 'POST',
6 | headers: {
7 | 'Accept': 'application/json',
8 | 'Authorization': 'Bearer ' + credentials.t
9 | },
10 | body: media
11 | })
12 | return await response.json()
13 | } catch(err) {
14 | console.log(err)
15 | }
16 | }
17 |
18 | const listPopular = async (signal) => {
19 | try {
20 | let response = await fetch('/api/media/popular', {
21 | method: 'GET',
22 | signal: signal,
23 | headers: {
24 | 'Accept': 'application/json',
25 | 'Content-Type': 'application/json'
26 | }
27 | })
28 | return await response.json()
29 | } catch(err) {
30 | console.log(err)
31 | }
32 | }
33 |
34 | const listByUser = async (params) => {
35 | try {
36 | let response = await fetch('/api/media/by/'+ params.userId, {
37 | method: 'GET',
38 | headers: {
39 | 'Accept': 'application/json',
40 | 'Content-Type': 'application/json'
41 | }
42 | })
43 | return await response.json()
44 | } catch(err) {
45 | console.log(err)
46 | }
47 | }
48 |
49 | const read = async (params, signal) => {
50 | try {
51 | let response = await fetch(config.serverUrl +'/api/media/' + params.mediaId, {
52 | method: 'GET',
53 | signal: signal
54 | })
55 | return await response.json()
56 | } catch(err) {
57 | console.log(err)
58 | }
59 | }
60 |
61 | const update = async (params, credentials, media) => {
62 | try {
63 | let response = await fetch('/api/media/' + params.mediaId, {
64 | method: 'PUT',
65 | headers: {
66 | 'Accept': 'application/json',
67 | 'Content-Type': 'application/json',
68 | 'Authorization': 'Bearer ' + credentials.t
69 | },
70 | body: JSON.stringify(media)
71 | })
72 | return await response.json()
73 | } catch(err) {
74 | console.log(err)
75 | }
76 | }
77 |
78 | const remove = async (params, credentials) => {
79 | try {
80 | let response = await fetch('/api/media/' + params.mediaId, {
81 | method: 'DELETE',
82 | headers: {
83 | 'Accept': 'application/json',
84 | 'Content-Type': 'application/json',
85 | 'Authorization': 'Bearer ' + credentials.t
86 | }
87 | })
88 | return await response.json()
89 | } catch(err) {
90 | console.log(err)
91 | }
92 | }
93 |
94 | const listRelated = async (params, signal) => {
95 | try {
96 | let response = await fetch('/api/media/related/'+ params.mediaId, {
97 | method: 'GET',
98 | signal: signal,
99 | headers: {
100 | 'Accept': 'application/json',
101 | 'Content-Type': 'application/json'
102 | }
103 | })
104 | return await response.json()
105 | } catch(err) {
106 | console.log(err)
107 | }
108 | }
109 |
110 | export {
111 | create,
112 | listPopular,
113 | listByUser,
114 | read,
115 | update,
116 | remove,
117 | listRelated
118 | }
119 |
--------------------------------------------------------------------------------
/client/routeConfig.js:
--------------------------------------------------------------------------------
1 | import PlayMedia from './media/PlayMedia'
2 | import { read } from './media/api-media.js'
3 |
4 | const routes = [
5 | {
6 | path: '/media/:mediaId',
7 | component: PlayMedia,
8 | loadData: (params) => read(params)
9 | }
10 |
11 | ]
12 | export default routes
13 |
--------------------------------------------------------------------------------
/client/theme.js:
--------------------------------------------------------------------------------
1 | import { createMuiTheme } from '@material-ui/core/styles'
2 | import { red, brown } from '@material-ui/core/colors'
3 |
4 | const theme = createMuiTheme({
5 | typography: {
6 | useNextVariants: true,
7 | },
8 | palette: {
9 | primary: {
10 | light: '#f05545',
11 | main: '#b71c1c',
12 | dark: '#7f0000',
13 | contrastText: '#fff',
14 | },
15 | secondary: {
16 | light: '#fbfffc',
17 | main: '#c8e6c9',
18 | dark: '#97b498',
19 | contrastText: '#37474f',
20 | },
21 | openTitle: red['500'],
22 | protectedTitle: brown['300'],
23 | type: 'light'
24 | },
25 | })
26 |
27 | export default theme
--------------------------------------------------------------------------------
/client/user/DeleteUser.js:
--------------------------------------------------------------------------------
1 | import React, {useState} from 'react'
2 | import PropTypes from 'prop-types'
3 | import IconButton from '@material-ui/core/IconButton'
4 | import Button from '@material-ui/core/Button'
5 | import DeleteIcon from '@material-ui/icons/Delete'
6 | import Dialog from '@material-ui/core/Dialog'
7 | import DialogActions from '@material-ui/core/DialogActions'
8 | import DialogContent from '@material-ui/core/DialogContent'
9 | import DialogContentText from '@material-ui/core/DialogContentText'
10 | import DialogTitle from '@material-ui/core/DialogTitle'
11 | import auth from './../auth/auth-helper'
12 | import {remove} from './api-user.js'
13 | import {Redirect} from 'react-router-dom'
14 |
15 | export default function DeleteUser(props) {
16 | const [open, setOpen] = useState(false)
17 | const [redirect, setRedirect] = useState(false)
18 |
19 | const jwt = auth.isAuthenticated()
20 | const clickButton = () => {
21 | setOpen(true)
22 | }
23 | const deleteAccount = () => {
24 | remove({
25 | userId: props.userId
26 | }, {t: jwt.token}).then((data) => {
27 | if (data && data.error) {
28 | console.log(data.error)
29 | } else {
30 | auth.clearJWT(() => console.log('deleted'))
31 | setRedirect(true)
32 | }
33 | })
34 | }
35 | const handleRequestClose = () => {
36 | setOpen(false)
37 | }
38 |
39 | if (redirect) {
40 | return
41 | }
42 | return (
43 |
44 |
45 |
46 |
47 |
48 | {"Delete Account"}
49 |
50 |
51 | Confirm to delete your account.
52 |
53 |
54 |
55 |
56 | Cancel
57 |
58 |
59 | Confirm
60 |
61 |
62 |
63 | )
64 |
65 | }
66 | DeleteUser.propTypes = {
67 | userId: PropTypes.string.isRequired
68 | }
69 |
70 |
--------------------------------------------------------------------------------
/client/user/EditProfile.js:
--------------------------------------------------------------------------------
1 | import React, {useState, useEffect} from 'react'
2 | import Card from '@material-ui/core/Card'
3 | import CardActions from '@material-ui/core/CardActions'
4 | import CardContent from '@material-ui/core/CardContent'
5 | import Button from '@material-ui/core/Button'
6 | import TextField from '@material-ui/core/TextField'
7 | import Typography from '@material-ui/core/Typography'
8 | import Icon from '@material-ui/core/Icon'
9 | import { makeStyles } from '@material-ui/core/styles'
10 | import auth from './../auth/auth-helper'
11 | import {read, update} from './api-user.js'
12 | import {Redirect} from 'react-router-dom'
13 |
14 | const useStyles = makeStyles(theme => ({
15 | card: {
16 | maxWidth: 600,
17 | margin: 'auto',
18 | textAlign: 'center',
19 | marginTop: theme.spacing(5),
20 | paddingBottom: theme.spacing(2)
21 | },
22 | title: {
23 | margin: theme.spacing(2),
24 | color: theme.palette.protectedTitle
25 | },
26 | error: {
27 | verticalAlign: 'middle'
28 | },
29 | textField: {
30 | marginLeft: theme.spacing(1),
31 | marginRight: theme.spacing(1),
32 | width: 300
33 | },
34 | submit: {
35 | margin: 'auto',
36 | marginBottom: theme.spacing(2)
37 | }
38 | }))
39 |
40 | export default function EditProfile({ match }) {
41 | const classes = useStyles()
42 | const [values, setValues] = useState({
43 | name: '',
44 | password: '',
45 | email: '',
46 | open: false,
47 | error: '',
48 | redirectToProfile: false
49 | })
50 | const jwt = auth.isAuthenticated()
51 |
52 | useEffect(() => {
53 | const abortController = new AbortController()
54 | const signal = abortController.signal
55 |
56 | read({
57 | userId: match.params.userId
58 | }, {t: jwt.token}, signal).then((data) => {
59 | if (data && data.error) {
60 | setValues({...values, error: data.error})
61 | } else {
62 | setValues({...values, name: data.name, email: data.email})
63 | }
64 | })
65 | return function cleanup(){
66 | abortController.abort()
67 | }
68 |
69 | }, [match.params.userId])
70 |
71 | const clickSubmit = () => {
72 | const user = {
73 | name: values.name || undefined,
74 | email: values.email || undefined,
75 | password: values.password || undefined
76 | }
77 | update({
78 | userId: match.params.userId
79 | }, {
80 | t: jwt.token
81 | }, user).then((data) => {
82 | if (data && data.error) {
83 | setValues({...values, error: data.error})
84 | } else {
85 | setValues({...values, userId: data._id, redirectToProfile: true})
86 | }
87 | })
88 | }
89 | const handleChange = name => event => {
90 | setValues({...values, [name]: event.target.value})
91 | }
92 |
93 | if (values.redirectToProfile) {
94 | return ( )
95 | }
96 | return (
97 |
98 |
99 |
100 | Edit Profile
101 |
102 |
103 |
104 |
105 | {
106 | values.error && (
107 | error
108 | {values.error}
109 | )
110 | }
111 |
112 |
113 | Submit
114 |
115 |
116 | )
117 | }
--------------------------------------------------------------------------------
/client/user/Profile.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react'
2 | import { makeStyles } from '@material-ui/core/styles'
3 | import Paper from '@material-ui/core/Paper'
4 | import List from '@material-ui/core/List'
5 | import ListItem from '@material-ui/core/ListItem'
6 | import ListItemAvatar from '@material-ui/core/ListItemAvatar'
7 | import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction'
8 | import ListItemText from '@material-ui/core/ListItemText'
9 | import Avatar from '@material-ui/core/Avatar'
10 | import IconButton from '@material-ui/core/IconButton'
11 | import Typography from '@material-ui/core/Typography'
12 | import Edit from '@material-ui/icons/Edit'
13 | import Person from '@material-ui/icons/Person'
14 | import Divider from '@material-ui/core/Divider'
15 | import DeleteUser from './DeleteUser'
16 | import auth from './../auth/auth-helper'
17 | import {read} from './api-user.js'
18 | import {Redirect, Link} from 'react-router-dom'
19 | import {listByUser} from '../media/api-media.js'
20 | import MediaList from '../media/MediaList'
21 |
22 | const useStyles = makeStyles(theme => ({
23 | root: theme.mixins.gutters({
24 | maxWidth: 600,
25 | margin: 'auto',
26 | padding: theme.spacing(3),
27 | marginTop: theme.spacing(5)
28 | }),
29 | title: {
30 | marginTop: theme.spacing(3),
31 | color: theme.palette.protectedTitle
32 | },
33 | avatar: {
34 | color: theme.palette.primary.contrastText,
35 | backgroundColor: theme.palette.primary.light
36 | }
37 | }))
38 |
39 | export default function Profile({ match }) {
40 | const classes = useStyles()
41 | const [user, setUser] = useState({})
42 | const [redirectToSignin, setRedirectToSignin] = useState(false)
43 | const jwt = auth.isAuthenticated()
44 | const [media, setMedia] = useState([])
45 |
46 | useEffect(() => {
47 | const abortController = new AbortController()
48 | const signal = abortController.signal
49 |
50 | read({
51 | userId: match.params.userId
52 | }, {t: jwt.token}, signal).then((data) => {
53 | if (data && data.error) {
54 | setRedirectToSignin(true)
55 | } else {
56 | setUser(data)
57 | }
58 | })
59 |
60 | return function cleanup(){
61 | abortController.abort()
62 | }
63 |
64 | }, [match.params.userId])
65 |
66 | useEffect(() => {
67 | const abortController = new AbortController()
68 | const signal = abortController.signal
69 |
70 | listByUser({
71 | userId: match.params.userId
72 | }, {t: jwt.token}, signal).then((data) => {
73 | if (data && data.error) {
74 | setRedirectToSignin(true)
75 | } else {
76 | setMedia(data)
77 | }
78 | })
79 |
80 | return function cleanup(){
81 | abortController.abort()
82 | }
83 |
84 | }, [match.params.userId])
85 |
86 | if (redirectToSignin) {
87 | return
88 | }
89 | return (
90 |
91 |
92 | Profile
93 |
94 |
95 |
96 |
97 |
98 | {user.name && user.name[0]}
99 |
100 |
101 | {
102 | auth.isAuthenticated().user && auth.isAuthenticated().user._id == user._id &&
103 | (
104 |
105 |
106 |
107 |
108 |
109 |
110 | )
111 | }
112 |
113 |
114 |
115 |
117 |
118 |
119 |
120 |
121 | )
122 | }
--------------------------------------------------------------------------------
/client/user/Signup.js:
--------------------------------------------------------------------------------
1 | import React, {useState} from 'react'
2 | import Card from '@material-ui/core/Card'
3 | import CardActions from '@material-ui/core/CardActions'
4 | import CardContent from '@material-ui/core/CardContent'
5 | import Button from '@material-ui/core/Button'
6 | import TextField from '@material-ui/core/TextField'
7 | import Typography from '@material-ui/core/Typography'
8 | import Icon from '@material-ui/core/Icon'
9 | import { makeStyles } from '@material-ui/core/styles'
10 | import {create} from './api-user.js'
11 | import Dialog from '@material-ui/core/Dialog'
12 | import DialogActions from '@material-ui/core/DialogActions'
13 | import DialogContent from '@material-ui/core/DialogContent'
14 | import DialogContentText from '@material-ui/core/DialogContentText'
15 | import DialogTitle from '@material-ui/core/DialogTitle'
16 | import {Link} from 'react-router-dom'
17 |
18 | const useStyles = makeStyles(theme => ({
19 | card: {
20 | maxWidth: 600,
21 | margin: 'auto',
22 | textAlign: 'center',
23 | marginTop: theme.spacing(5),
24 | paddingBottom: theme.spacing(2)
25 | },
26 | error: {
27 | verticalAlign: 'middle'
28 | },
29 | title: {
30 | marginTop: theme.spacing(2),
31 | color: theme.palette.openTitle
32 | },
33 | textField: {
34 | marginLeft: theme.spacing(1),
35 | marginRight: theme.spacing(1),
36 | width: 300
37 | },
38 | submit: {
39 | margin: 'auto',
40 | marginBottom: theme.spacing(2)
41 | }
42 | }))
43 |
44 | export default function Signup() {
45 | const classes = useStyles()
46 | const [values, setValues] = useState({
47 | name: '',
48 | password: '',
49 | email: '',
50 | open: false,
51 | error: ''
52 | })
53 |
54 | const handleChange = name => event => {
55 | setValues({ ...values, [name]: event.target.value })
56 | }
57 |
58 | const clickSubmit = () => {
59 | const user = {
60 | name: values.name || undefined,
61 | email: values.email || undefined,
62 | password: values.password || undefined
63 | }
64 | create(user).then((data) => {
65 | if (data.error) {
66 | setValues({ ...values, error: data.error})
67 | } else {
68 | setValues({ ...values, error: '', open: true})
69 | }
70 | })
71 | }
72 |
73 | return (
74 |
75 |
76 |
77 | Sign Up
78 |
79 |
80 |
81 |
82 | {
83 | values.error && (
84 | error
85 | {values.error} )
86 | }
87 |
88 |
89 | Submit
90 |
91 |
92 |
93 | New Account
94 |
95 |
96 | New account successfully created.
97 |
98 |
99 |
100 |
101 |
102 | Sign In
103 |
104 |
105 |
106 |
107 |
108 | )
109 | }
--------------------------------------------------------------------------------
/client/user/Users.js:
--------------------------------------------------------------------------------
1 | import React, {useState, useEffect} from 'react'
2 | import { makeStyles } from '@material-ui/core/styles'
3 | import Paper from '@material-ui/core/Paper'
4 | import List from '@material-ui/core/List'
5 | import ListItem from '@material-ui/core/ListItem'
6 | import ListItemAvatar from '@material-ui/core/ListItemAvatar'
7 | import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction'
8 | import ListItemText from '@material-ui/core/ListItemText'
9 | import Avatar from '@material-ui/core/Avatar'
10 | import IconButton from '@material-ui/core/IconButton'
11 | import Typography from '@material-ui/core/Typography'
12 | import ArrowForward from '@material-ui/icons/ArrowForward'
13 | import Person from '@material-ui/icons/Person'
14 | import {Link} from 'react-router-dom'
15 | import {list} from './api-user.js'
16 |
17 | const useStyles = makeStyles(theme => ({
18 | root: theme.mixins.gutters({
19 | padding: theme.spacing(1),
20 | margin: theme.spacing(5)
21 | }),
22 | title: {
23 | margin: `${theme.spacing(4)}px 0 ${theme.spacing(2)}px`,
24 | color: theme.palette.openTitle
25 | }
26 | }))
27 |
28 | export default function Users() {
29 | const classes = useStyles()
30 | const [users, setUsers] = useState([])
31 |
32 | useEffect(() => {
33 | const abortController = new AbortController()
34 | const signal = abortController.signal
35 |
36 | list(signal).then((data) => {
37 | if (data && data.error) {
38 | console.log(data.error)
39 | } else {
40 | setUsers(data)
41 | }
42 | })
43 |
44 | return function cleanup(){
45 | abortController.abort()
46 | }
47 | }, [])
48 |
49 |
50 | return (
51 |
52 |
53 | All Users
54 |
55 |
56 | {users.map((item, i) => {
57 | return
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 | })
73 | }
74 |
75 |
76 | )
77 | }
78 |
--------------------------------------------------------------------------------
/client/user/api-user.js:
--------------------------------------------------------------------------------
1 | const create = async (user) => {
2 | try {
3 | let response = await fetch('/api/users/', {
4 | method: 'POST',
5 | headers: {
6 | 'Accept': 'application/json',
7 | 'Content-Type': 'application/json'
8 | },
9 | body: JSON.stringify(user)
10 | })
11 | return await response.json()
12 | } catch(err) {
13 | console.log(err)
14 | }
15 | }
16 |
17 | const list = async (signal) => {
18 | try {
19 | let response = await fetch('/api/users/', {
20 | method: 'GET',
21 | signal: signal,
22 | })
23 | return await response.json()
24 | } catch(err) {
25 | console.log(err)
26 | }
27 | }
28 |
29 | const read = async (params, credentials, signal) => {
30 | try {
31 | let response = await fetch('/api/users/' + params.userId, {
32 | method: 'GET',
33 | signal: signal,
34 | headers: {
35 | 'Accept': 'application/json',
36 | 'Content-Type': 'application/json',
37 | 'Authorization': 'Bearer ' + credentials.t
38 | }
39 | })
40 | return await response.json()
41 | } catch(err) {
42 | console.log(err)
43 | }
44 | }
45 |
46 | const update = async (params, credentials, user) => {
47 | try {
48 | let response = await fetch('/api/users/' + params.userId, {
49 | method: 'PUT',
50 | headers: {
51 | 'Accept': 'application/json',
52 | 'Content-Type': 'application/json',
53 | 'Authorization': 'Bearer ' + credentials.t
54 | },
55 | body: JSON.stringify(user)
56 | })
57 | return await response.json()
58 | } catch(err) {
59 | console.log(err)
60 | }
61 | }
62 |
63 | const remove = async (params, credentials) => {
64 | try {
65 | let response = await fetch('/api/users/' + params.userId, {
66 | method: 'DELETE',
67 | headers: {
68 | 'Accept': 'application/json',
69 | 'Content-Type': 'application/json',
70 | 'Authorization': 'Bearer ' + credentials.t
71 | }
72 | })
73 | return await response.json()
74 | } catch(err) {
75 | console.log(err)
76 | }
77 | }
78 |
79 | export {
80 | create,
81 | list,
82 | read,
83 | update,
84 | remove
85 | }
--------------------------------------------------------------------------------
/config/config.js:
--------------------------------------------------------------------------------
1 | const config = {
2 | env: process.env.NODE_ENV || 'development',
3 | port: process.env.PORT || 3000,
4 | jwtSecret: process.env.JWT_SECRET || "YOUR_secret_key",
5 | mongoUri: process.env.MONGODB_URI ||
6 | process.env.MONGO_HOST ||
7 | 'mongodb://' + (process.env.IP || 'localhost') + ':' +
8 | (process.env.MONGO_PORT || '27017') +
9 | '/mernproject',
10 | serverUrl: process.env.serverUrl || 'http://localhost:3000'
11 | }
12 |
13 | export default config
14 |
--------------------------------------------------------------------------------
/nodemon.json:
--------------------------------------------------------------------------------
1 | {
2 | "verbose": false,
3 | "watch": [
4 | "./server"
5 | ],
6 | "exec": "webpack --mode=development --config webpack.config.server.js && node ./dist/server.generated.js"
7 | }
8 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "mern-mediastream",
3 | "version": "2.0.0",
4 | "description": "A MERN stack based media streaming application",
5 | "author": "Shama Hoque",
6 | "license": "MIT",
7 | "keywords": [
8 | "react",
9 | "express",
10 | "mongodb",
11 | "node",
12 | "mern"
13 | ],
14 | "repository": {
15 | "type": "git",
16 | "url": "https://github.com/shamahoque/mern-mediastream.git"
17 | },
18 | "homepage": "https://github.com/shamahoque/mern-mediastream",
19 | "main": "./dist/server.generated.js",
20 | "scripts": {
21 | "development": "nodemon",
22 | "build": "webpack --config webpack.config.client.production.js && webpack --mode=production --config webpack.config.server.js",
23 | "start": "NODE_ENV=production node ./dist/server.generated.js"
24 | },
25 | "engines": {
26 | "node": "13.12.0",
27 | "npm": "6.14.4"
28 | },
29 | "devDependencies": {
30 | "@babel/core": "7.9.0",
31 | "@babel/preset-env": "7.9.0",
32 | "@babel/preset-react": "7.9.4",
33 | "babel-loader": "8.1.0",
34 | "file-loader": "6.0.0",
35 | "nodemon": "2.0.2",
36 | "webpack": "4.42.1",
37 | "webpack-cli": "3.3.11",
38 | "webpack-dev-middleware": "3.7.2",
39 | "webpack-hot-middleware": "2.25.0",
40 | "webpack-node-externals": "1.7.2"
41 | },
42 | "dependencies": {
43 | "@hot-loader/react-dom": "16.13.0",
44 | "@material-ui/core": "4.9.8",
45 | "@material-ui/icons": "4.9.1",
46 | "body-parser": "1.19.0",
47 | "compression": "1.7.4",
48 | "cookie-parser": "1.4.5",
49 | "cors": "2.8.5",
50 | "express": "4.17.1",
51 | "express-jwt": "5.3.1",
52 | "formidable": "1.2.2",
53 | "helmet": "3.22.0",
54 | "isomorphic-fetch": "2.2.1",
55 | "jsonwebtoken": "8.5.1",
56 | "lodash": "4.17.15",
57 | "mongoose": "5.9.7",
58 | "react": "16.13.1",
59 | "react-dom": "16.13.1",
60 | "react-hot-loader": "4.12.20",
61 | "react-player": "1.15.3",
62 | "react-router": "5.1.2",
63 | "react-router-config": "5.1.1",
64 | "react-router-dom": "5.1.2",
65 | "screenfull": "5.0.2"
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/server/controllers/auth.controller.js:
--------------------------------------------------------------------------------
1 | import User from '../models/user.model'
2 | import jwt from 'jsonwebtoken'
3 | import expressJwt from 'express-jwt'
4 | import config from './../../config/config'
5 |
6 | const signin = async (req, res) => {
7 | try {
8 | let user = await User.findOne({
9 | "email": req.body.email
10 | })
11 | if (!user)
12 | return res.status('401').json({
13 | error: "User not found"
14 | })
15 |
16 | if (!user.authenticate(req.body.password)) {
17 | return res.status('401').send({
18 | error: "Email and password don't match."
19 | })
20 | }
21 |
22 | const token = jwt.sign({
23 | _id: user._id
24 | }, config.jwtSecret)
25 |
26 | res.cookie("t", token, {
27 | expire: new Date() + 9999
28 | })
29 |
30 | return res.json({
31 | token,
32 | user: {
33 | _id: user._id,
34 | name: user.name,
35 | email: user.email
36 | }
37 | })
38 |
39 | } catch (err) {
40 |
41 | return res.status('401').json({
42 | error: "Could not sign in"
43 | })
44 |
45 | }
46 | }
47 |
48 | const signout = (req, res) => {
49 | res.clearCookie("t")
50 | return res.status('200').json({
51 | message: "signed out"
52 | })
53 | }
54 |
55 | const requireSignin = expressJwt({
56 | secret: config.jwtSecret,
57 | userProperty: 'auth'
58 | })
59 |
60 | const hasAuthorization = (req, res, next) => {
61 | const authorized = req.profile && req.auth && req.profile._id == req.auth._id
62 | if (!(authorized)) {
63 | return res.status('403').json({
64 | error: "User is not authorized"
65 | })
66 | }
67 | next()
68 | }
69 |
70 | export default {
71 | signin,
72 | signout,
73 | requireSignin,
74 | hasAuthorization
75 | }
76 |
--------------------------------------------------------------------------------
/server/controllers/media.controller.js:
--------------------------------------------------------------------------------
1 | import Media from '../models/media.model'
2 | import extend from 'lodash/extend'
3 | import errorHandler from './../helpers/dbErrorHandler'
4 | import formidable from 'formidable'
5 | import fs from 'fs'
6 |
7 | //media streaming
8 | import mongoose from 'mongoose'
9 | let gridfs = null
10 | mongoose.connection.on('connected', () => {
11 | gridfs = new mongoose.mongo.GridFSBucket(mongoose.connection.db)
12 | })
13 |
14 | const create = (req, res) => {
15 | let form = new formidable.IncomingForm()
16 | form.keepExtensions = true
17 | form.parse(req, async (err, fields, files) => {
18 | if (err) {
19 | return res.status(400).json({
20 | error: "Video could not be uploaded"
21 | })
22 | }
23 | let media = new Media(fields)
24 | media.postedBy= req.profile
25 | if(files.video){
26 | let writestream = gridfs.openUploadStream(media._id, {
27 | contentType: files.video.type || 'binary/octet-stream'})
28 | fs.createReadStream(files.video.path).pipe(writestream)
29 | }
30 | try {
31 | let result = await media.save()
32 | res.status(200).json(result)
33 | }
34 | catch (err){
35 | return res.status(400).json({
36 | error: errorHandler.getErrorMessage(err)
37 | })
38 | }
39 | })
40 | }
41 |
42 | const mediaByID = async (req, res, next, id) => {
43 | try{
44 | let media = await Media.findById(id).populate('postedBy', '_id name').exec()
45 | if (!media)
46 | return res.status('400').json({
47 | error: "Media not found"
48 | })
49 | req.media = media
50 | let files = await gridfs.find({filename:media._id}).toArray()
51 | if (!files[0]) {
52 | return res.status(404).send({
53 | error: 'No video found'
54 | })
55 | }
56 | req.file = files[0]
57 | next()
58 | }catch(err) {
59 | return res.status(404).send({
60 | error: 'Could not retrieve media file'
61 | })
62 | }
63 | }
64 |
65 | const video = (req, res) => {
66 | const range = req.headers["range"]
67 | if (range && typeof range === "string") {
68 | const parts = range.replace(/bytes=/, "").split("-")
69 | const partialstart = parts[0]
70 | const partialend = parts[1]
71 |
72 | const start = parseInt(partialstart, 10)
73 | const end = partialend ? parseInt(partialend, 10) : req.file.length - 1
74 | const chunksize = (end - start) + 1
75 |
76 | res.writeHead(206, {
77 | 'Accept-Ranges': 'bytes',
78 | 'Content-Length': chunksize,
79 | 'Content-Range': 'bytes ' + start + '-' + end + '/' + req.file.length,
80 | 'Content-Type': req.file.contentType
81 | })
82 |
83 | let downloadStream = gridfs.openDownloadStream(req.file._id, {start, end: end+1})
84 | downloadStream.pipe(res)
85 | downloadStream.on('error', () => {
86 | res.sendStatus(404)
87 | })
88 | downloadStream.on('end', () => {
89 | res.end()
90 | })
91 | } else {
92 | res.header('Content-Length', req.file.length)
93 | res.header('Content-Type', req.file.contentType)
94 |
95 | let downloadStream = gridfs.openDownloadStream(req.file._id)
96 | downloadStream.pipe(res)
97 | downloadStream.on('error', () => {
98 | res.sendStatus(404)
99 | })
100 | downloadStream.on('end', () => {
101 | res.end()
102 | })
103 | }
104 | }
105 |
106 | const listPopular = async (req, res) => {
107 | try{
108 | let media = await Media.find({}).limit(9)
109 | .populate('postedBy', '_id name')
110 | .sort('-views')
111 | .exec()
112 | res.json(media)
113 | } catch(err){
114 | return res.status(400).json({
115 | error: errorHandler.getErrorMessage(err)
116 | })
117 | }
118 | }
119 |
120 | const listByUser = async (req, res) => {
121 | try{
122 | let media = await Media.find({postedBy: req.profile._id})
123 | .populate('postedBy', '_id name')
124 | .sort('-created')
125 | .exec()
126 | res.json(media)
127 | } catch(err){
128 | return res.status(400).json({
129 | error: errorHandler.getErrorMessage(err)
130 | })
131 | }
132 | }
133 |
134 | const read = (req, res) => {
135 | return res.json(req.media)
136 | }
137 |
138 | const incrementViews = async (req, res, next) => {
139 | try {
140 | await Media.findByIdAndUpdate(req.media._id, {$inc: {"views": 1}}, {new: true}).exec()
141 | next()
142 | } catch(err){
143 | return res.status(400).json({
144 | error: errorHandler.getErrorMessage(err)
145 | })
146 | }
147 | }
148 |
149 | const update = async (req, res) => {
150 | try {
151 | let media = req.media
152 | media = extend(media, req.body)
153 | media.updated = Date.now()
154 | await media.save()
155 | res.json(media)
156 | } catch(err){
157 | return res.status(400).json({
158 | error: errorHandler.getErrorMessage(err)
159 | })
160 | }
161 | }
162 |
163 | const isPoster = (req, res, next) => {
164 | let isPoster = req.media && req.auth && req.media.postedBy._id == req.auth._id
165 | if(!isPoster){
166 | return res.status('403').json({
167 | error: "User is not authorized"
168 | })
169 | }
170 | next()
171 | }
172 |
173 | const remove = async (req, res) => {
174 | try {
175 | let media = req.media
176 | let deletedMedia = await media.remove()
177 | gridfs.delete(req.file._id)
178 | res.json(deletedMedia)
179 | } catch(err) {
180 | return res.status(400).json({
181 | error: errorHandler.getErrorMessage(err)
182 | })
183 | }
184 | }
185 |
186 | const listRelated = async (req, res) => {
187 | try {
188 | let media = await Media.find({ "_id": { "$ne": req.media }, "genre": req.media.genre})
189 | .limit(4)
190 | .sort('-views')
191 | .populate('postedBy', '_id name')
192 | .exec()
193 | res.json(media)
194 | } catch (err) {
195 | return res.status(400).json({
196 | error: errorHandler.getErrorMessage(err)
197 | })
198 | }
199 | }
200 |
201 | export default {
202 | create,
203 | mediaByID,
204 | video,
205 | listPopular,
206 | listByUser,
207 | read,
208 | incrementViews,
209 | update,
210 | isPoster,
211 | remove,
212 | listRelated
213 | }
214 |
--------------------------------------------------------------------------------
/server/controllers/user.controller.js:
--------------------------------------------------------------------------------
1 | import User from '../models/user.model'
2 | import extend from 'lodash/extend'
3 | import errorHandler from './../helpers/dbErrorHandler'
4 |
5 | const create = async (req, res) => {
6 | const user = new User(req.body)
7 | try {
8 | await user.save()
9 | return res.status(200).json({
10 | message: "Successfully signed up!"
11 | })
12 | } catch (err) {
13 | return res.status(400).json({
14 | error: errorHandler.getErrorMessage(err)
15 | })
16 | }
17 | }
18 |
19 | /**
20 | * Load user and append to req.
21 | */
22 | const userByID = async (req, res, next, id) => {
23 | try {
24 | let user = await User.findById(id)
25 | if (!user)
26 | return res.status('400').json({
27 | error: "User not found"
28 | })
29 | req.profile = user
30 | next()
31 | } catch (err) {
32 | return res.status('400').json({
33 | error: "Could not retrieve user"
34 | })
35 | }
36 | }
37 |
38 | const read = (req, res) => {
39 | req.profile.hashed_password = undefined
40 | req.profile.salt = undefined
41 | return res.json(req.profile)
42 | }
43 |
44 | const list = async (req, res) => {
45 | try {
46 | let users = await User.find().select('name email updated created')
47 | res.json(users)
48 | } catch (err) {
49 | return res.status(400).json({
50 | error: errorHandler.getErrorMessage(err)
51 | })
52 | }
53 | }
54 |
55 | const update = async (req, res) => {
56 | try {
57 | let user = req.profile
58 | user = extend(user, req.body)
59 | user.updated = Date.now()
60 | await user.save()
61 | user.hashed_password = undefined
62 | user.salt = undefined
63 | res.json(user)
64 | } catch (err) {
65 | return res.status(400).json({
66 | error: errorHandler.getErrorMessage(err)
67 | })
68 | }
69 | }
70 |
71 | const remove = async (req, res) => {
72 | try {
73 | let user = req.profile
74 | let deletedUser = await user.remove()
75 | deletedUser.hashed_password = undefined
76 | deletedUser.salt = undefined
77 | res.json(deletedUser)
78 | } catch (err) {
79 | return res.status(400).json({
80 | error: errorHandler.getErrorMessage(err)
81 | })
82 | }
83 | }
84 |
85 | export default {
86 | create,
87 | userByID,
88 | read,
89 | list,
90 | remove,
91 | update
92 | }
--------------------------------------------------------------------------------
/server/devBundle.js:
--------------------------------------------------------------------------------
1 | import config from './../config/config'
2 | import webpack from 'webpack'
3 | import webpackMiddleware from 'webpack-dev-middleware'
4 | import webpackHotMiddleware from 'webpack-hot-middleware'
5 | import webpackConfig from './../webpack.config.client.js'
6 |
7 | const compile = (app) => {
8 | if(config.env === "development"){
9 | const compiler = webpack(webpackConfig)
10 | const middleware = webpackMiddleware(compiler, {
11 | publicPath: webpackConfig.output.publicPath
12 | })
13 | app.use(middleware)
14 | app.use(webpackHotMiddleware(compiler))
15 | }
16 | }
17 |
18 | export default {
19 | compile
20 | }
21 |
--------------------------------------------------------------------------------
/server/express.js:
--------------------------------------------------------------------------------
1 | import express from 'express'
2 | import path from 'path'
3 | import bodyParser from 'body-parser'
4 | import cookieParser from 'cookie-parser'
5 | import compress from 'compression'
6 | import cors from 'cors'
7 | import helmet from 'helmet'
8 | import Template from './../template'
9 | import userRoutes from './routes/user.routes'
10 | import authRoutes from './routes/auth.routes'
11 | import mediaRoutes from './routes/media.routes'
12 |
13 | // modules for server side rendering
14 | import React from 'react'
15 | import ReactDOMServer from 'react-dom/server'
16 | import MainRouter from './../client/MainRouter'
17 | import { StaticRouter } from 'react-router-dom'
18 |
19 | import { ServerStyleSheets, ThemeProvider } from '@material-ui/styles'
20 | import theme from './../client/theme'
21 | //end
22 |
23 | //For SSR with data
24 | import { matchRoutes } from 'react-router-config'
25 | import routes from './../client/routeConfig'
26 | import 'isomorphic-fetch'
27 | //end
28 |
29 | //comment out before building for production
30 | import devBundle from './devBundle'
31 |
32 | const CURRENT_WORKING_DIR = process.cwd()
33 | const app = express()
34 |
35 | //comment out before building for production
36 | devBundle.compile(app)
37 |
38 | //For SSR with data
39 | const loadBranchData = (location) => {
40 | const branch = matchRoutes(routes, location)
41 | const promises = branch.map(({ route, match }) => {
42 | return route.loadData
43 | ? route.loadData(branch[0].match.params)
44 | : Promise.resolve(null)
45 | })
46 | return Promise.all(promises)
47 | }
48 |
49 | // parse body params and attache them to req.body
50 | app.use(bodyParser.json())
51 | app.use(bodyParser.urlencoded({ extended: true }))
52 | app.use(cookieParser())
53 | app.use(compress())
54 | // secure apps by setting various HTTP headers
55 | app.use(helmet())
56 | // enable CORS - Cross Origin Resource Sharing
57 | app.use(cors())
58 |
59 | app.use('/dist', express.static(path.join(CURRENT_WORKING_DIR, 'dist')))
60 |
61 | // mount routes
62 | app.use('/', userRoutes)
63 | app.use('/', authRoutes)
64 | app.use('/', mediaRoutes)
65 |
66 | app.get('*', (req, res) => {
67 | const sheets = new ServerStyleSheets()
68 | const context = {}
69 |
70 | loadBranchData(req.url).then(data => {
71 | const markup = ReactDOMServer.renderToString(
72 | sheets.collect(
73 |
74 |
75 |
76 |
77 |
78 | )
79 | )
80 | if (context.url) {
81 | return res.redirect(303, context.url)
82 | }
83 | const css = sheets.toString()
84 | res.status(200).send(Template({
85 | markup: markup,
86 | css: css
87 | }))
88 | }).catch(err => {
89 | res.status(500).send({"error": "Could not load React view with data"})
90 | })
91 | })
92 |
93 | // Catch unauthorised errors
94 | app.use((err, req, res, next) => {
95 | if (err.name === 'UnauthorizedError') {
96 | res.status(401).json({"error" : err.name + ": " + err.message})
97 | }else if (err) {
98 | res.status(400).json({"error" : err.name + ": " + err.message})
99 | console.log(err)
100 | }
101 | })
102 |
103 | export default app
104 |
--------------------------------------------------------------------------------
/server/helpers/dbErrorHandler.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | /**
4 | * Get unique error field name
5 | */
6 | const getUniqueErrorMessage = (err) => {
7 | let output
8 | try {
9 | let fieldName = err.message.substring(err.message.lastIndexOf('.$') + 2, err.message.lastIndexOf('_1'))
10 | output = fieldName.charAt(0).toUpperCase() + fieldName.slice(1) + ' already exists'
11 | } catch (ex) {
12 | output = 'Unique field already exists'
13 | }
14 |
15 | return output
16 | }
17 |
18 | /**
19 | * Get the error message from error object
20 | */
21 | const getErrorMessage = (err) => {
22 | let message = ''
23 |
24 | if (err.code) {
25 | switch (err.code) {
26 | case 11000:
27 | case 11001:
28 | message = getUniqueErrorMessage(err)
29 | break
30 | default:
31 | message = 'Something went wrong'
32 | }
33 | } else {
34 | for (let errName in err.errors) {
35 | if (err.errors[errName].message) message = err.errors[errName].message
36 | }
37 | }
38 |
39 | return message
40 | }
41 |
42 | export default {getErrorMessage}
43 |
--------------------------------------------------------------------------------
/server/models/media.model.js:
--------------------------------------------------------------------------------
1 | import mongoose from 'mongoose'
2 | const MediaSchema = new mongoose.Schema({
3 | title: {
4 | type: String,
5 | required: 'title is required'
6 | },
7 | description: String,
8 | genre: String,
9 | views: {type: Number, default: 0},
10 | postedBy: {type: mongoose.Schema.ObjectId, ref: 'User'},
11 | created: {
12 | type: Date,
13 | default: Date.now
14 | },
15 | updated: {
16 | type: Date
17 | }
18 | })
19 |
20 | export default mongoose.model('Media', MediaSchema)
21 |
--------------------------------------------------------------------------------
/server/models/user.model.js:
--------------------------------------------------------------------------------
1 | import mongoose from 'mongoose'
2 | import crypto from 'crypto'
3 | const UserSchema = new mongoose.Schema({
4 | name: {
5 | type: String,
6 | trim: true,
7 | required: 'Name is required'
8 | },
9 | email: {
10 | type: String,
11 | trim: true,
12 | unique: 'Email already exists',
13 | match: [/.+\@.+\..+/, 'Please fill a valid email address'],
14 | required: 'Email is required'
15 | },
16 | hashed_password: {
17 | type: String,
18 | required: "Password is required"
19 | },
20 | salt: String,
21 | updated: Date,
22 | created: {
23 | type: Date,
24 | default: Date.now
25 | }
26 | })
27 |
28 | UserSchema
29 | .virtual('password')
30 | .set(function(password) {
31 | this._password = password
32 | this.salt = this.makeSalt()
33 | this.hashed_password = this.encryptPassword(password)
34 | })
35 | .get(function() {
36 | return this._password
37 | })
38 |
39 | UserSchema.path('hashed_password').validate(function(v) {
40 | if (this._password && this._password.length < 6) {
41 | this.invalidate('password', 'Password must be at least 6 characters.')
42 | }
43 | if (this.isNew && !this._password) {
44 | this.invalidate('password', 'Password is required')
45 | }
46 | }, null)
47 |
48 | UserSchema.methods = {
49 | authenticate: function(plainText) {
50 | return this.encryptPassword(plainText) === this.hashed_password
51 | },
52 | encryptPassword: function(password) {
53 | if (!password) return ''
54 | try {
55 | return crypto
56 | .createHmac('sha1', this.salt)
57 | .update(password)
58 | .digest('hex')
59 | } catch (err) {
60 | return ''
61 | }
62 | },
63 | makeSalt: function() {
64 | return Math.round((new Date().valueOf() * Math.random())) + ''
65 | }
66 | }
67 |
68 | export default mongoose.model('User', UserSchema)
69 |
--------------------------------------------------------------------------------
/server/routes/auth.routes.js:
--------------------------------------------------------------------------------
1 | import express from 'express'
2 | import authCtrl from '../controllers/auth.controller'
3 |
4 | const router = express.Router()
5 |
6 | router.route('/auth/signin')
7 | .post(authCtrl.signin)
8 | router.route('/auth/signout')
9 | .get(authCtrl.signout)
10 |
11 | export default router
12 |
--------------------------------------------------------------------------------
/server/routes/media.routes.js:
--------------------------------------------------------------------------------
1 | import express from 'express'
2 | import userCtrl from '../controllers/user.controller'
3 | import authCtrl from '../controllers/auth.controller'
4 | import mediaCtrl from '../controllers/media.controller'
5 |
6 | const router = express.Router()
7 |
8 | router.route('/api/media/new/:userId')
9 | .post(authCtrl.requireSignin, mediaCtrl.create)
10 |
11 | router.route('/api/media/video/:mediaId')
12 | .get(mediaCtrl.video)
13 |
14 | router.route('/api/media/popular')
15 | .get(mediaCtrl.listPopular)
16 |
17 | router.route('/api/media/related/:mediaId')
18 | .get(mediaCtrl.listRelated)
19 |
20 | router.route('/api/media/by/:userId')
21 | .get(mediaCtrl.listByUser)
22 |
23 | router.route('/api/media/:mediaId')
24 | .get( mediaCtrl.incrementViews, mediaCtrl.read)
25 | .put(authCtrl.requireSignin, mediaCtrl.isPoster, mediaCtrl.update)
26 | .delete(authCtrl.requireSignin, mediaCtrl.isPoster, mediaCtrl.remove)
27 |
28 | router.param('userId', userCtrl.userByID)
29 | router.param('mediaId', mediaCtrl.mediaByID)
30 |
31 | export default router
32 |
--------------------------------------------------------------------------------
/server/routes/user.routes.js:
--------------------------------------------------------------------------------
1 | import express from 'express'
2 | import userCtrl from '../controllers/user.controller'
3 | import authCtrl from '../controllers/auth.controller'
4 |
5 | const router = express.Router()
6 |
7 | router.route('/api/users')
8 | .get(userCtrl.list)
9 | .post(userCtrl.create)
10 |
11 | router.route('/api/users/:userId')
12 | .get(authCtrl.requireSignin, userCtrl.read)
13 | .put(authCtrl.requireSignin, authCtrl.hasAuthorization, userCtrl.update)
14 | .delete(authCtrl.requireSignin, authCtrl.hasAuthorization, userCtrl.remove)
15 |
16 | router.param('userId', userCtrl.userByID)
17 |
18 | export default router
19 |
--------------------------------------------------------------------------------
/server/server.js:
--------------------------------------------------------------------------------
1 | import config from './../config/config'
2 | import app from './express'
3 | import mongoose from 'mongoose'
4 |
5 | // Connection URL
6 | mongoose.Promise = global.Promise
7 | mongoose.connect(config.mongoUri, { useNewUrlParser: true, useCreateIndex: true, useUnifiedTopology: true, useFindAndModify: false })
8 | mongoose.connection.on('error', () => {
9 | throw new Error(`unable to connect to database: ${config.mongoUri}`)
10 | })
11 |
12 | app.listen(config.port, (err) => {
13 | if (err) {
14 | console.log(err)
15 | }
16 | console.info('Server started on port %s.', config.port)
17 | })
18 |
--------------------------------------------------------------------------------
/template.js:
--------------------------------------------------------------------------------
1 | export default ({markup, css}) => {
2 | return `
3 |
4 |
5 |
6 | MERN Mediastream
7 |
8 |
9 |
14 |
15 |
16 | ${markup}
17 |
18 |
19 |
20 | `
21 | }
22 |
--------------------------------------------------------------------------------
/webpack.config.client.js:
--------------------------------------------------------------------------------
1 | const path = require('path')
2 | const webpack = require('webpack')
3 | const CURRENT_WORKING_DIR = process.cwd()
4 |
5 | const config = {
6 | name: "browser",
7 | mode: "development",
8 | devtool: 'eval-source-map',
9 | entry: [
10 | 'react-hot-loader/patch',
11 | 'webpack-hot-middleware/client?reload=true',
12 | path.join(CURRENT_WORKING_DIR, 'client/main.js')
13 | ],
14 | output: {
15 | path: path.join(CURRENT_WORKING_DIR , '/dist'),
16 | filename: 'bundle.js',
17 | publicPath: '/dist/'
18 | },
19 | module: {
20 | rules: [
21 | {
22 | test: /\.jsx?$/,
23 | exclude: /node_modules/,
24 | use: [
25 | 'babel-loader'
26 | ]
27 | },
28 | {
29 | test: /\.(ttf|eot|svg|gif|jpg|png)(\?[\s\S]+)?$/,
30 | use: 'file-loader'
31 | }
32 | ]
33 | }, plugins: [
34 | new webpack.HotModuleReplacementPlugin(),
35 | new webpack.NoEmitOnErrorsPlugin()
36 | ]
37 | }
38 |
39 | module.exports = config
40 |
--------------------------------------------------------------------------------
/webpack.config.client.production.js:
--------------------------------------------------------------------------------
1 | const path = require('path')
2 | const CURRENT_WORKING_DIR = process.cwd()
3 |
4 | const config = {
5 | mode: "production",
6 | entry: [
7 | path.join(CURRENT_WORKING_DIR, 'client/main.js')
8 | ],
9 | output: {
10 | path: path.join(CURRENT_WORKING_DIR , '/dist'),
11 | filename: 'bundle.js',
12 | publicPath: "/dist/"
13 | },
14 | module: {
15 | rules: [
16 | {
17 | test: /\.jsx?$/,
18 | exclude: /node_modules/,
19 | use: [
20 | 'babel-loader'
21 | ]
22 | },
23 | {
24 | test: /\.(ttf|eot|svg|gif|jpg|png)(\?[\s\S]+)?$/,
25 | use: 'file-loader'
26 | }
27 | ]
28 | }
29 | }
30 |
31 | module.exports = config
32 |
--------------------------------------------------------------------------------
/webpack.config.server.js:
--------------------------------------------------------------------------------
1 | const path = require('path')
2 | const nodeExternals = require('webpack-node-externals')
3 | const CURRENT_WORKING_DIR = process.cwd()
4 |
5 | const config = {
6 | name: "server",
7 | entry: [ path.join(CURRENT_WORKING_DIR , './server/server.js') ],
8 | target: "node",
9 | output: {
10 | path: path.join(CURRENT_WORKING_DIR , '/dist/'),
11 | filename: "server.generated.js",
12 | publicPath: '/dist/',
13 | libraryTarget: "commonjs2"
14 | },
15 | externals: [nodeExternals()],
16 | module: {
17 | rules: [
18 | {
19 | test: /\.js$/,
20 | exclude: /node_modules/,
21 | use: [ 'babel-loader' ]
22 | },
23 | {
24 | test: /\.(ttf|eot|svg|gif|jpg|png)(\?[\s\S]+)?$/,
25 | use: 'file-loader'
26 | }
27 | ]
28 | }
29 | }
30 |
31 | module.exports = config
32 |
--------------------------------------------------------------------------------