├── .babelrc
├── .nsprc
├── server
├── public
│ ├── doge.jpg
│ ├── login.png
│ ├── logout.png
│ └── account_circle.png
├── search.js
├── server.js
└── db.js
├── .editorconfig
├── reset.sh
├── src
├── index.js
├── index.html
└── index.sass
├── webpack.config.js
├── .eslintrc.json
├── LICENSE
├── .gitignore
├── components
├── Doge.jsx
├── VideoSelectionCard.jsx
├── Trending.jsx
├── Feedback.jsx
├── SearchResults.jsx
├── Navbar.jsx
├── TrendingVideo.jsx
├── VideoUploadCard.jsx
├── ThumbnailRow.jsx
├── ConfirmDelete.jsx
├── Sidebar.jsx
├── Upload.jsx
├── NewLogin.jsx
├── RoutesSwitch.jsx
├── App.jsx
├── Register.jsx
├── Login.jsx
├── PublicProfile.jsx
├── Comments.jsx
├── SettingsPage.jsx
└── WatchPage.jsx
├── package.json
├── Creating_DB.sql
└── README.md
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | "react",
4 | "env"
5 | ]
6 | }
7 |
--------------------------------------------------------------------------------
/.nsprc:
--------------------------------------------------------------------------------
1 | {
2 | "exceptions": [
3 | "https://nodesecurity.io/advisories/532"
4 | ]
5 | }
6 |
--------------------------------------------------------------------------------
/server/public/doge.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yrahul3910/video-sharing-site/HEAD/server/public/doge.jpg
--------------------------------------------------------------------------------
/server/public/login.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yrahul3910/video-sharing-site/HEAD/server/public/login.png
--------------------------------------------------------------------------------
/server/public/logout.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yrahul3910/video-sharing-site/HEAD/server/public/logout.png
--------------------------------------------------------------------------------
/server/public/account_circle.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yrahul3910/video-sharing-site/HEAD/server/public/account_circle.png
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # editorconfig.org
2 | root = true
3 |
4 | [*]
5 | indent_style = space
6 | indent_size = 4
7 | end_of_line = lf
8 | charset = utf-8
9 | trim_trailing_whitespace = true
10 | insert_final_newline = true
11 |
12 | [*.md]
13 | trim_trailing_whitespace = false
14 |
--------------------------------------------------------------------------------
/reset.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Delete all indices from search
4 | echo "Deleting search indices..."
5 | curl -XDELETE http://localhost:9200/qtube > /dev/null 2> /dev/null
6 |
7 | # Delete folders
8 | echo "Deleting user data..."
9 | rm -rf videos/
10 | rm -rf users/
11 |
12 | # Re-run MySQL script
13 | echo "Running MySQL script..."
14 | mysql -u $1 -p < Creating_DB.sql
15 |
16 | echo "Project reset complete."
17 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-undef */
2 | // App entry point
3 | import "./index.sass";
4 | import React from "react";
5 | import ReactDOM from "react-dom";
6 | import {HashRouter as Router} from "react-router-dom";
7 |
8 | import RoutesSwitch from "../components/RoutesSwitch.jsx";
9 |
10 | ReactDOM.render(
11 |
12 |
13 | , document.getElementById("app")
14 | );
15 |
--------------------------------------------------------------------------------
/src/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | OpenVideo
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | import path from "path";
2 | import HtmlWebpackPlugin from "html-webpack-plugin";
3 |
4 | export default {
5 | entry: [
6 | path.resolve(__dirname, "src/index")
7 | ],
8 | output: {
9 | path: path.resolve(__dirname, "src"),
10 | publicPath: "/",
11 | filename: "bundle.js"
12 | },
13 | plugins: [
14 | new HtmlWebpackPlugin({
15 | template: "src/index.html",
16 | inject: true
17 | })
18 | ],
19 | module: {
20 | loaders: [
21 | {test: /\.jsx$/, exclude: /node_modules/, loaders: ["babel-loader"]},
22 | {test: /\.js$/, exclude: /node_modules/, loaders: ["babel-loader"]},
23 | {test: /\.css$/, loaders: ["style-loader","css-loader"]},
24 | {test: /\.sass$/, loaders: ["style-loader", "css-loader", "sass-loader"]}
25 | ]
26 | }
27 | };
28 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "root": true,
3 | "env": {
4 | "browser": true,
5 | "node": true,
6 | "mocha": true
7 | },
8 | "extends": ["eslint:recommended", "plugin:react/recommended"],
9 | "plugins": [
10 | "react"
11 | ],
12 | "parserOptions": {
13 | "ecmaVersion": 7,
14 | "ecmaFeatures": {
15 | "experimentalObjectRestSpread": true,
16 | "jsx": true
17 | },
18 | "sourceType": "module"
19 | },
20 | "rules": {
21 | "indent": [
22 | "error",
23 | 4
24 | ],
25 | "linebreak-style": [
26 | "error",
27 | "unix"
28 | ],
29 | "quotes": [
30 | "error",
31 | "double"
32 | ],
33 | "semi": [
34 | "error",
35 | "always"
36 | ],
37 | "no-console": 1
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2017 Rahul Yedida
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 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 |
8 | # Runtime data
9 | pids
10 | *.pid
11 | *.seed
12 | *.pid.lock
13 |
14 | # Directory for instrumented libs generated by jscoverage/JSCover
15 | lib-cov
16 |
17 | # Coverage directory used by tools like istanbul
18 | coverage
19 |
20 | # nyc test coverage
21 | .nyc_output
22 |
23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
24 | .grunt
25 |
26 | # Bower dependency directory (https://bower.io/)
27 | bower_components
28 |
29 | # node-waf configuration
30 | .lock-wscript
31 |
32 | # Compiled binary addons (http://nodejs.org/api/addons.html)
33 | build/Release
34 |
35 | # Dependency directories
36 | node_modules/
37 | jspm_packages/
38 |
39 | # Typescript v1 declaration files
40 | typings/
41 |
42 | # Optional npm cache directory
43 | .npm
44 |
45 | # Optional eslint cache
46 | .eslintcache
47 |
48 | # Optional REPL history
49 | .node_repl_history
50 |
51 | # Output of 'npm pack'
52 | *.tgz
53 |
54 | # Yarn Integrity file
55 | .yarn-integrity
56 |
57 | # dotenv environment variables file
58 | .env
59 |
60 | videos/
61 | users/
62 |
--------------------------------------------------------------------------------
/components/Doge.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import PropTypes from "prop-types";
3 | import {Link} from "react-router-dom";
4 |
5 | import Navbar from "./Navbar.jsx";
6 | import Sidebar from "./Sidebar.jsx";
7 |
8 | class Doge extends React.Component {
9 | render() {
10 | return (
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | It seems you haven't subscribed to any users yet. Why not head over to the
21 | trending page to find some inspiration?
22 |
23 |
24 |
25 |
26 | );
27 | }
28 | }
29 |
30 | Doge.propTypes = {
31 | user: PropTypes.object,
32 | toggleLogin: PropTypes.func.isRequired
33 | };
34 |
35 | export default Doge;
36 |
--------------------------------------------------------------------------------
/components/VideoSelectionCard.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import PropTypes from "prop-types";
3 |
4 | class VideoSelectionCard extends React.Component {
5 | constructor(props) {
6 | super(props);
7 | }
8 |
9 | render() {
10 | return (
11 |
12 |
13 |
14 | cloud_upload
15 |
16 |
17 |
18 |
20 |
21 |
{this.props.text}
22 |
23 |
24 |
25 |
26 |
27 | );
28 | }
29 | }
30 |
31 | VideoSelectionCard.propTypes = {
32 | selectClick: PropTypes.func.isRequired,
33 | changeClick: PropTypes.func.isRequired,
34 | text: PropTypes.string.isRequired
35 | };
36 |
37 | export default VideoSelectionCard;
38 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "YoutubeClone",
3 | "version": "1.0.0",
4 | "main": "index.js",
5 | "license": "MIT",
6 | "scripts": {
7 | "start": "npm-run-all --parallel server security-check lint:watch",
8 | "server": "babel-node server/server.js",
9 | "security-check": "nsp check",
10 | "lint": "esw",
11 | "lint:watch": "npm run lint -- --watch"
12 | },
13 | "dependencies": {
14 | "bcrypt": "^1.0.3",
15 | "body-parser": "^1.18.2",
16 | "elasticsearch": "^14.0.0",
17 | "eslint-watch": "^3.1.3",
18 | "helmet": "^3.9.0",
19 | "jsonwebtoken": "^8.1.0",
20 | "lodash.chunk": "^4.2.0",
21 | "lodash.groupby": "^4.6.0",
22 | "moment": "^2.19.2",
23 | "numeral": "^2.0.6",
24 | "react-router-dom": "^4.2.2"
25 | },
26 | "devDependencies": {
27 | "babel-cli": "^6.26.0",
28 | "babel-core": "^6.26.0",
29 | "babel-loader": "^7.1.2",
30 | "babel-preset-env": "^1.6.1",
31 | "babel-preset-react": "^6.24.1",
32 | "babel-register": "^6.26.0",
33 | "compression": "^1.7.1",
34 | "cors": "^2.8.4",
35 | "css-loader": "^0.28.7",
36 | "dotenv": "^4.0.0",
37 | "eslint": "^4.12.0",
38 | "eslint-plugin-import": "^2.8.0",
39 | "eslint-plugin-react": "^7.5.1",
40 | "express": "^4.16.2",
41 | "formidable": "^1.1.1",
42 | "html-webpack-plugin": "^2.30.1",
43 | "mysql": "^2.15.0",
44 | "node-sass": "^4.7.2",
45 | "npm-run-all": "^4.1.2",
46 | "nsp": "^3.1.0",
47 | "open": "^0.0.5",
48 | "prop-types": "^15.6.0",
49 | "react": "^16.1.1",
50 | "react-dom": "^16.1.1",
51 | "request": "^2.83.0",
52 | "sass-loader": "^6.0.6",
53 | "style-loader": "^0.19.0",
54 | "webpack": "^3.8.1",
55 | "webpack-dev-middleware": "^1.12.2"
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/server/search.js:
--------------------------------------------------------------------------------
1 | const elasticsearch = require("elasticsearch");
2 | const esClient = new elasticsearch.Client({
3 | host: "127.0.0.1:9200",
4 | log: "error"
5 | });
6 |
7 | // Index new object to ElasticSearch
8 | exports.index = (index, type, data) => {
9 | /*if(type == "user") {
10 | esClient.index({
11 | index,
12 | type,
13 | id: data.username,
14 | body: {
15 | name: data.name,
16 | username: data.username
17 | }
18 | }, (error) => {
19 | if (error)
20 | throw error;
21 | });
22 | } */
23 | if (type == "video") {
24 | esClient.index({
25 | index,
26 | type,
27 | id: data.video_id,
28 | body: {
29 | description: data.description,
30 | title: data.title,
31 | username: data.username,
32 | thumbnail: data.thumbnail
33 | }
34 | }, (error) => {
35 | if (error)
36 | throw error;
37 | });
38 | }
39 | };
40 |
41 | // Get all the indices from ElasticSearch
42 | exports.indices = () => {
43 | return esClient.cat.indices({v: true})
44 | .then(console.log) // eslint-disable-line no-console
45 | .catch(err => {
46 | throw err;
47 | });
48 | };
49 |
50 | // Delete document from index
51 | exports.deleteDoc = (index, type, id, func) => {
52 | esClient.delete({
53 | index,
54 | type,
55 | id
56 | }, () => {
57 | func();
58 | });
59 | };
60 |
61 | // Search for documents
62 | exports.search = (index, body) => {
63 | return esClient.search({index, body});
64 | };
65 |
66 | module.exports = exports;
67 |
68 |
--------------------------------------------------------------------------------
/components/Trending.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-undef */
2 | import React from "react";
3 | import PropTypes from "prop-types";
4 |
5 | import Navbar from "./Navbar.jsx";
6 | import TrendingVideo from "./TrendingVideo.jsx";
7 | import Sidebar from "./Sidebar.jsx";
8 |
9 | class Trending extends React.Component {
10 | constructor(props) {
11 | super(props);
12 | this.state = {videos: null};
13 | }
14 |
15 | componentDidMount() {
16 | $.getJSON("http://localhost:8000/api/trending", (data) => {
17 | if (data.success)
18 | this.setState({videos: data.videos});
19 | });
20 | }
21 |
22 | render() {
23 | let content;
24 | if (!this.state.videos)
25 | content = Loading... ;
26 | else {
27 | content = this.state.videos.map((element, index) => {
28 | return ;
36 | });
37 | }
38 | return (
39 |
40 |
41 |
42 |
43 | {content}
44 |
45 |
46 | );
47 | }
48 | }
49 |
50 | Trending.propTypes = {
51 | user: PropTypes.object,
52 | toggleLogin: PropTypes.func.isRequired
53 | };
54 |
55 | export default Trending;
56 |
--------------------------------------------------------------------------------
/components/Feedback.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-undef */
2 | import React from "react";
3 | import PropTypes from "prop-types";
4 | import {Redirect} from "react-router-dom";
5 |
6 | import Navbar from "./Navbar.jsx";
7 |
8 | class Feedback extends React.Component {
9 | constructor(props) {
10 | super(props);
11 | this.state = {loggedIn: (this.props.user ? true : false)};
12 | this.click = this.click.bind(this);
13 | }
14 |
15 | click() {
16 | let feedback = $("#feedback").val();
17 | if (feedback.trim() == "") {
18 | Materialize.toast("You may not submit empty feedback.", 2000, "rounded");
19 | return;
20 | }
21 | $.post("http://localhost:8000/api/feedback", {
22 | token: localStorage.getItem("token"),
23 | feedback
24 | }, (res) => {
25 | Materialize.toast(res.message, 4000);
26 | });
27 | }
28 |
29 | render() {
30 | if (!this.state.loggedIn) {
31 | Materialize.toast("You need to be logged in!", 4000);
32 | return (
33 |
34 | );
35 | }
36 | return (
37 |
38 |
39 |
40 |
Tell us how we’re doing!
41 |
42 |
43 |
44 | Feedback
45 |
46 |
47 |
50 |
51 |
52 | );
53 | }
54 | }
55 |
56 | Feedback.propTypes = {
57 | user: PropTypes.object
58 | };
59 |
60 | export default Feedback;
61 |
--------------------------------------------------------------------------------
/components/SearchResults.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-undef */
2 | import React from "react";
3 | import PropTypes from "prop-types";
4 |
5 | import Navbar from "./Navbar.jsx";
6 | import Sidebar from "./Sidebar.jsx";
7 | import TrendingVideo from "./TrendingVideo.jsx";
8 |
9 | class SearchResults extends React.Component {
10 | constructor(props) {
11 | super(props);
12 | this.state = {results: []};
13 | }
14 |
15 | componentDidMount() {
16 | $.post("http://localhost:8000/api/search", {
17 | query: this.props.match.params.q
18 | }, (data) => {
19 | this.setState({results: data});
20 | });
21 | }
22 |
23 | render() {
24 | let {time, results} = this.state.results;
25 | let timeDiv = Fetched {results ? results.length : 0} results in {time} ms.
;
26 | // Results div
27 | let div = this.state.results.results ? this.state.results.results.map((result, i) => {
28 | return ;
34 | }) :
;
35 |
36 | return (
37 |
38 |
39 |
40 |
41 |
42 | {timeDiv}
43 |
44 |
45 | {div}
46 |
47 |
48 |
49 | );
50 | }
51 | }
52 |
53 | SearchResults.propTypes = {
54 | user: PropTypes.object,
55 | toggleLogin: PropTypes.func.isRequired,
56 | match: PropTypes.object
57 | };
58 |
59 | export default SearchResults;
60 |
--------------------------------------------------------------------------------
/components/Navbar.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-undef */
2 | import React from "react";
3 | import PropTypes from "prop-types";
4 |
5 | import {Link, Redirect} from "react-router-dom";
6 |
7 | class Navbar extends React.Component {
8 | /*
9 | props:
10 | dp: base64 encoded image string
11 | */
12 | constructor(props) {
13 | super(props);
14 | this.state = {search: 0, q: ""};
15 | this.search = this.search.bind(this);
16 | this.change = this.change.bind(this);
17 | }
18 |
19 | change() {
20 | if ($(".search-input").val())
21 | this.setState({q: $(".search-input").val()});
22 | }
23 |
24 | search() {
25 | window.location = "/#/search/" + this.state.q;
26 | window.location.reload();
27 | }
28 |
29 | render() {
30 | return (
31 |
32 |
33 |
34 |
OpenVideo
36 |
37 |
38 |
39 |
46 |
47 |
48 |
49 |
50 |
51 | );
52 | }
53 | }
54 |
55 | Navbar.propTypes = {
56 | dp: PropTypes.string
57 | };
58 |
59 | export default Navbar;
60 |
--------------------------------------------------------------------------------
/components/TrendingVideo.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import PropTypes from "prop-types";
3 | import numeral from "numeral";
4 | import {Link} from "react-router-dom";
5 |
6 | class TrendingVideo extends React.Component {
7 | render() {
8 | return (
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | {this.props.title}
18 |
19 |
20 |
21 |
22 |
23 |
24 | {this.props.user +
25 | (this.props.views ? (" • " + numeral(this.props.views).format("0.0a") + " views") : "") +
26 | (this.props.age ? ((typeof(this.props.age) == "number") ? (" • " + this.props.age + " days ago")
27 | : (" • " + this.props.age))
28 | : "") }
29 |
30 |
31 |
32 |
33 |
34 | {this.props.desc ? this.props.desc : ""}
35 |
36 |
37 |
38 |
39 | );
40 | }
41 | }
42 |
43 | TrendingVideo.propTypes = {
44 | thumbnail: PropTypes.string.isRequired,
45 | title: PropTypes.string.isRequired,
46 | user: PropTypes.string.isRequired, // this is the username
47 | views: PropTypes.string,
48 | age: PropTypes.any,
49 | desc: PropTypes.string,
50 | video_id: PropTypes.number.isRequired
51 | };
52 |
53 | export default TrendingVideo;
54 |
--------------------------------------------------------------------------------
/components/VideoUploadCard.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import PropTypes from "prop-types";
3 |
4 | class VideoUploadCard extends React.Component {
5 | render() {
6 | return (
7 |
8 |
9 |
10 | Upload Options
11 |
12 |
13 |
14 |
15 |
16 |
17 | Video Title
18 |
19 |
20 |
21 |
22 |
23 | Video Description
24 |
25 |
26 |
35 |
36 |
37 |
40 |
41 |
42 | );
43 | }
44 | }
45 |
46 | VideoUploadCard.propTypes = {
47 | submit: PropTypes.func.isRequired
48 | };
49 |
50 | export default VideoUploadCard;
51 |
--------------------------------------------------------------------------------
/components/ThumbnailRow.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import PropTypes from "prop-types";
3 |
4 | class ThumbnailRow extends React.Component {
5 | /*
6 | props:
7 | data: An object that has a title and an array, thumbnails.
8 | The array has 4 elements only.
9 | {
10 | "title": "Title of the row",
11 | "thumbnails": [
12 | {
13 | url: (string) URL of the video,
14 | img: (string) thumbnail source,
15 | title: (string) Title of video,
16 | views: (string) views of the video,
17 | channel: {
18 | // This is a user now since channel support is removed
19 | title: "Name of the channel",
20 | url: "Link to channel"
21 | },
22 | date: (string) Date like (x months/days/hours ago)
23 | }
24 | ]
25 | }
26 | */
27 | render() {
28 | let innerDivs = this.props.data.thumbnails.map((t) =>
29 |
30 |
31 |
32 |
33 |
38 |
43 |
44 |
45 | {`${t.views} views • ${t.date}`}
46 |
47 |
48 |
49 | );
50 | return (
51 |
52 |
53 | {this.props.data.title}
54 |
55 |
56 | {innerDivs}
57 |
58 |
59 | );
60 | }
61 | }
62 |
63 | ThumbnailRow.propTypes = {
64 | data: PropTypes.object.isRequired
65 | };
66 |
67 | export default ThumbnailRow;
68 |
--------------------------------------------------------------------------------
/src/index.sass:
--------------------------------------------------------------------------------
1 | .round
2 | border-radius: 50%
3 |
4 | .nav-dp
5 | width: 40px
6 | height: 40px
7 | margin-top: 8px
8 |
9 | .nav-element
10 | margin-left: 10px
11 | margin-right: 12px
12 |
13 | .no-material
14 | all: initial !important
15 | font-family: 'Roboto' !important
16 |
17 | .search-input
18 | padding-left: 15px !important
19 | width: 90% !important
20 | height: 100% !important
21 |
22 | .thumbnail
23 | width: 220px !important
24 | height: 118px !important
25 | object-fit: fill !important
26 |
27 | .thumbnail-channel
28 | font-size: 12px
29 | color: darkgray
30 |
31 | .no-gap
32 | margin-bottom: 0
33 |
34 | .center
35 | position: relative
36 | left: 50%
37 | transform: translateX(-50%)
38 |
39 | body
40 | background-color: #fafafa
41 |
42 | .ion-icon-css
43 | float: left
44 | display: inline-block
45 | margin-top: 10px
46 |
47 | .sidebar-item
48 | margin-left: 50px
49 |
50 | .profile-dp
51 | width: 80px
52 | height: 80px
53 | border-radius: 50%
54 |
55 | .profile-subscribers
56 | color: darkgray
57 | font-size: 16px
58 | margin-top: 0
59 |
60 | .video
61 | margin-bottom: 0 !important
62 | left: 0 !important
63 | transform: translateX(0) !important
64 |
65 | p
66 | margin-bottom: 0 !important
67 | margin-top: 3px !important
68 | text-align: left
69 | margin-left: 10px
70 |
71 | &.video-desc
72 | margin-top: 10px !important
73 |
74 | .shrink
75 | transform: scale(0.9)
76 | -ms-transform: scale(0.9)
77 | -webkit-transform: scale(0.9)
78 | -o-transform: scale(0.9)
79 | -moz-transform: scale(0.9)
80 |
81 | .link
82 | cursor: pointer
83 |
84 | .search-bar
85 | margin-top: 10px
86 | background: rgba(0, 0, 0, 0.4) !important
87 | border-radius: 4px !important
88 | transition: background 100ms ease-in
89 | border: 1px solid rgba(0, 0, 0, 0) !important
90 |
91 | &:focus-within
92 | border: 1px solid rgba(0, 0, 0, 0.12) !important
93 | background: white !important
94 | box-shadow: 0 1px 1px rgba(0, 0, 0, 0.24) !important
95 |
96 | .search, .search-login
97 | border: none !important
98 | outline: none !important
99 | background: transparent !important
100 |
101 | .search-login
102 | color: gray !important
103 |
104 | .search-login:focus
105 | color: black !important
106 |
107 | .background-video
108 | top: 0
109 | left: 0
110 | bottom: 0
111 | right: 0
112 | min-height: 100%
113 | min-width: 100%
114 | position: fixed
115 |
--------------------------------------------------------------------------------
/components/ConfirmDelete.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-undef */
2 | import React from "react";
3 | import {Redirect} from "react-router-dom";
4 | import PropTypes from "prop-types";
5 |
6 | import Navbar from "./Navbar.jsx";
7 |
8 | class ConfirmDelete extends React.Component {
9 | constructor(props) {
10 | super(props);
11 |
12 | this.state = {confirmed: false};
13 | this.confirm = this.confirm.bind(this);
14 | }
15 |
16 | confirm() {
17 | let pwd = $("#password").val();
18 | $.ajax({
19 | url: "http://localhost:8000/api/user",
20 | method: "DELETE",
21 | contentType: "application/json",
22 | data: JSON.stringify({
23 | token: localStorage.getItem("token"),
24 | pwd
25 | }),
26 | success: (data) => {
27 | if (data.success) {
28 | Materialize.toast("Your account has been deleted.", 2500, "rounded");
29 | localStorage.clear();
30 | this.props.toggleLogin(null);
31 | this.setState({confirmed: true});
32 | } else
33 | Materialize.toast(data.message, 4000, "rounded");
34 | }
35 | });
36 | }
37 |
38 | render() {
39 | if (this.state.confirmed)
40 | return ;
41 |
42 | return (
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
You are about to delete your account. This action cannot be undone! To confirm,
52 | please re-enter your password.
53 |
54 | Password
55 |
56 |
57 |
64 |
65 |
66 | );
67 | }
68 | }
69 |
70 | ConfirmDelete.propTypes = {
71 | user: PropTypes.object,
72 | toggleLogin: PropTypes.func.isRequired
73 | };
74 |
75 | export default ConfirmDelete;
76 |
--------------------------------------------------------------------------------
/components/Sidebar.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import PropTypes from "prop-types";
3 | import {Link} from "react-router-dom";
4 |
5 | class Sidebar extends React.Component {
6 | constructor(props) {
7 | super(props);
8 | this.logout = this.logout.bind(this);
9 | }
10 |
11 | logout() {
12 | if (this.props.loggedIn) {
13 | localStorage.removeItem("token");
14 | this.props.toggleLogin(null);
15 | }
16 | }
17 |
18 | render() {
19 | // Elements only shown to users that are logged in.
20 | let userElements = this.props.loggedIn ?
21 | (
22 |
23 |
24 |
25 | settings
26 | Settings
27 |
28 |
29 |
30 |
31 | feedback
32 | Feedback
33 |
34 |
35 |
36 | ) :
;
37 |
38 | let uploadLink = this.props.loggedIn ? (
39 |
40 |
41 | file_upload
42 | Upload
43 |
44 |
45 | ) :
;
46 |
47 | return (
48 |
49 |
50 |
51 | home
52 | Home
53 |
54 |
55 |
56 |
57 | trending_up
58 | Trending
59 |
60 |
61 | {uploadLink}
62 | {userElements}
63 |
64 |
65 |
67 | Log {this.props.loggedIn ? "Out" : "In"}
68 |
69 |
70 |
71 | );
72 | }
73 | }
74 |
75 | Sidebar.propTypes = {
76 | loggedIn: PropTypes.bool.isRequired,
77 | toggleLogin: PropTypes.func.isRequired
78 | };
79 |
80 | export default Sidebar;
81 |
--------------------------------------------------------------------------------
/components/Upload.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-undef */
2 | import React from "react";
3 | import {Redirect} from "react-router-dom";
4 | import PropTypes from "prop-types";
5 |
6 | import Navbar from "./Navbar.jsx";
7 | import VideoSelectionCard from "./VideoSelectionCard.jsx";
8 | import VideoUploadCard from "./VideoUploadCard.jsx";
9 |
10 | class Upload extends React.Component {
11 | constructor(props) {
12 | super(props);
13 | this.selectClick = this.selectClick.bind(this);
14 | this.changeClick = this.changeClick.bind(this);
15 | this.postData = this.postData.bind(this);
16 | this.submitData = this.submitData.bind(this);
17 | this.state = {selected: false, firstClick: true, form_data: null, uploadComplete: false};
18 | }
19 |
20 | postData() {
21 | $.ajax({
22 | url: "http://localhost:8000/api/upload",
23 | method: "POST",
24 | processData: false,
25 | contentType: false,
26 | dataType: "json",
27 | data: this.state.form_data,
28 | success: (data) => {
29 | if (data.success) {
30 | Materialize.toast("Video successfully uploaded!", 3000, "rounded");
31 | this.setState({uploadComplete: true});
32 | } else
33 | Materialize.toast(data.message, 3500, "rounded");
34 | }
35 | });
36 | }
37 |
38 | selectClick(e) {
39 | let filename = $("#videoUpload").val();
40 | if (filename) {
41 | let fd = new FormData();
42 | fd.append("video", $("#videoUpload")[0].files[0]);
43 |
44 | this.setState({selected: true, form_data: fd});
45 | e.preventDefault();
46 | }
47 | }
48 |
49 | changeClick() {
50 | let filename = $("#videoUpload").val();
51 | if (filename)
52 | this.setState({firstClick: false});
53 | }
54 |
55 | submitData() {
56 | let fd = this.state.form_data;
57 | let title = $("#video_title").val();
58 | let desc = $("#video_desc").val();
59 | let thumbnail = $("#thumbnailUpload")[0].files[0];
60 | let token = localStorage.getItem("token");
61 |
62 | fd.append("title", title);
63 | fd.append("desc", desc);
64 | fd.append("thumbnail", thumbnail);
65 | fd.append("token", token);
66 |
67 | this.setState({form_data: fd});
68 | this.postData();
69 | }
70 |
71 | render() {
72 | if (!this.props.user)
73 | return (
74 |
75 | );
76 | if (this.state.uploadComplete)
77 | return (
78 |
79 | );
80 |
81 | let card;
82 | if (this.state.selected)
83 | card = ;
84 | else
85 | card = ;
89 | return (
90 |
91 |
92 | {card}
93 |
94 | );
95 | }
96 | }
97 |
98 | Upload.propTypes = {
99 | user: PropTypes.object
100 | };
101 |
102 | export default Upload;
103 |
--------------------------------------------------------------------------------
/components/NewLogin.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-undef */
2 | import React from "react";
3 | import {Link, Redirect} from "react-router-dom";
4 | import PropTypes from "prop-types";
5 |
6 | class NewLogin extends React.Component {
7 | constructor(props) {
8 | super(props);
9 | this.state = {loggedIn: (this.props.user ? true : false)};
10 | this.click = this.click.bind(this);
11 | }
12 |
13 | click() {
14 | $.post("http://localhost:8000/api/authenticate", {
15 | username: $("#username").val(),
16 | password: $("#password").val()
17 | }, (data) => {
18 | if (!data.success)
19 | Materialize.toast("Please check your details and try again.", 3000, "rounded");
20 | else {
21 | localStorage.setItem("token", data.token);
22 | this.props.toggleLogin(data.user);
23 | this.setState({loggedIn: true});
24 | }
25 | });
26 | }
27 |
28 | componentDidMount() {
29 | $(".background-video")[0].play();
30 | }
31 |
32 | render() {
33 | if (this.state.loggedIn)
34 | return (
35 |
36 | );
37 |
38 | return (
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
49 | OpenVideo
50 |
51 |
52 |
53 |
56 |
Enter your sign-in credentials
57 |
72 |
73 |
74 | );
75 | }
76 | }
77 |
78 | NewLogin.propTypes = {
79 | user: PropTypes.object,
80 | toggleLogin: PropTypes.func.isRequired
81 | };
82 |
83 | export default NewLogin;
84 |
--------------------------------------------------------------------------------
/components/RoutesSwitch.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-undef */
2 | import React from "react";
3 | import {Switch, Route} from "react-router-dom";
4 |
5 | import App from "./App.jsx";
6 | import Login from "./Login.jsx";
7 | import NewLogin from "./NewLogin.jsx";
8 | import Register from "./Register.jsx";
9 | import Upload from "./Upload.jsx";
10 | import Feedback from "./Feedback.jsx";
11 | import Trending from "./Trending.jsx";
12 | import PublicProfile from "./PublicProfile.jsx";
13 | import SearchResults from "./SearchResults.jsx";
14 | import SettingsPage from "./SettingsPage.jsx";
15 | import ConfirmDelete from "./ConfirmDelete.jsx";
16 | import WatchPage from "./WatchPage.jsx";
17 | import Doge from "./Doge.jsx";
18 |
19 | class RoutesSwitch extends React.Component {
20 | constructor(props) {
21 | super(props);
22 | this.state = {user: {}};
23 | this.toggleLogin = this.toggleLogin.bind(this);
24 | }
25 |
26 | componentDidMount() {
27 | $.post("http://localhost:8000/api/whoami", {token: localStorage.getItem("token")},
28 | (data) => {
29 | if (data.success) {
30 | // The user is valid, Set state!
31 | this.setState({user: data.user});
32 | } else
33 | this.setState({user: null});
34 | }
35 | );
36 | }
37 |
38 | toggleLogin(user) {
39 | this.setState({user});
40 | }
41 |
42 | render() {
43 | return (
44 |
45 |
46 |
47 | } />
48 |
49 |
50 | } />
51 |
52 |
53 | } />
54 |
55 |
56 | } />
57 |
58 |
59 | } />
60 |
61 |
62 | } />
63 |
64 |
65 | } />
66 |
67 |
69 | } />
70 |
71 |
73 | } />
74 |
75 |
77 | } />
78 |
79 |
81 | } />
82 |
83 |
84 | } />
85 |
86 |
87 | } />
88 |
89 | );
90 | }
91 | }
92 |
93 | export default RoutesSwitch;
94 |
--------------------------------------------------------------------------------
/Creating_DB.sql:
--------------------------------------------------------------------------------
1 | DROP DATABASE IF EXISTS video_sharing;
2 | CREATE DATABASE IF NOT EXISTS video_sharing;
3 | USE video_sharing;
4 |
5 | CREATE TABLE users (
6 | PRIMARY KEY (username),
7 | username VARCHAR(30) NOT NULL,
8 | name VARCHAR(30) NOT NULL,
9 | pwd VARCHAR(100) NOT NULL,
10 | dp VARCHAR(200) -- display picture
11 | );
12 |
13 | CREATE TABLE videos (
14 | PRIMARY KEY (video_id),
15 | video_id INT AUTO_INCREMENT,
16 | description VARCHAR(200),
17 | upload_date DATE NOT NULL,
18 | username VARCHAR(30) NOT NULL,
19 | FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE,
20 | title VARCHAR(20) NOT NULL,
21 | video_path VARCHAR(100) NOT NULL,
22 | thumbnail VARCHAR(200) NOT NULL
23 | );
24 |
25 | CREATE TABLE video_ratings (
26 | PRIMARY KEY (username, video_id),
27 | username VARCHAR(30) NOT NULL,
28 | FOREIGN KEY(username) REFERENCES users(username) ON DELETE CASCADE,
29 | video_id INT NOT NULL,
30 | FOREIGN KEY(video_id) REFERENCES videos(video_id) ON DELETE CASCADE,
31 | rating INT NOT NULL,
32 | CONSTRAINT rating_in_range
33 | CHECK(rating IN (-1, 1))
34 | );
35 |
36 | CREATE TABLE comments (
37 | PRIMARY KEY (comment_id),
38 | comment_id INT AUTO_INCREMENT,
39 | username VARCHAR(30) NOT NULL,
40 | FOREIGN KEY(username) REFERENCES users(username) ON DELETE CASCADE,
41 | video_id INT NOT NULL,
42 | FOREIGN KEY(video_id) REFERENCES videos(video_id) ON DELETE CASCADE,
43 | comment VARCHAR(150) NOT NULL,
44 | comment_date DATE NOT NULL
45 | );
46 |
47 | CREATE TABLE replies (
48 | PRIMARY KEY (reply_id),
49 | reply_id INT AUTO_INCREMENT,
50 | comment_id INT NOT NULL,
51 | FOREIGN KEY(comment_id) REFERENCES comments(comment_id) ON DELETE CASCADE,
52 | username VARCHAR(30) NOT NULL,
53 | FOREIGN KEY(username) REFERENCES users(username) ON DELETE CASCADE,
54 | reply_text VARCHAR(150) NOT NULL,
55 | reply_date DATE NOT NULL
56 | );
57 |
58 | /* Users subscribe to other users */
59 | CREATE TABLE subscriptions (
60 | PRIMARY KEY (username, subscriber),
61 | username VARCHAR(30) NOT NULL,
62 | FOREIGN KEY(username) REFERENCES users(username) ON DELETE CASCADE,
63 | subscriber VARCHAR(30) NOT NULL,
64 | FOREIGN KEY(subscriber) REFERENCES users(username) ON DELETE CASCADE
65 | );
66 |
67 | CREATE TABLE feedback (
68 | PRIMARY KEY (username),
69 | username VARCHAR(30),
70 | FOREIGN KEY(username) REFERENCES users(username) ON DELETE CASCADE,
71 | comment VARCHAR(300) NOT NULL
72 | );
73 |
74 | CREATE TABLE video_views (
75 | PRIMARY KEY (video_id),
76 | video_id INT,
77 | FOREIGN KEY(video_id) REFERENCES videos(video_id) ON DELETE CASCADE,
78 | views INT NOT NULL
79 | );
80 |
81 | DELIMITER //
82 | CREATE TRIGGER before_users_insert
83 | BEFORE INSERT ON users
84 | FOR EACH ROW
85 | BEGIN
86 | IF NEW.name REGEXP '[]!@#$%^&*()+\=[\{};\':"\\|,.<>/?-]' OR NEW.username REGEXP '[]!@#$%^&*()+\=[\{};\':"\\|,.<>/?-]' THEN
87 | SIGNAL SQLSTATE '45000' set message_text="Special characters aren't allowed in usernames and names.";
88 | END IF;
89 | END //
90 |
91 | CREATE TRIGGER subscribe_user_to_self
92 | AFTER INSERT ON users
93 | FOR EACH ROW
94 | BEGIN
95 | INSERT INTO subscriptions VALUES (NEW.username, NEW.username);
96 | END //
97 |
98 | CREATE TRIGGER add_video_views_entry
99 | AFTER INSERT ON videos
100 | FOR EACH ROW
101 | BEGIN
102 | INSERT INTO video_views VALUES (NEW.video_id, 0);
103 | END //
104 |
105 | CREATE PROCEDURE increment_views(IN vid_id INT)
106 | BEGIN
107 | UPDATE video_views
108 | SET views = views + 1
109 | WHERE video_id = vid_id;
110 | END //
111 | DELIMITER ;
112 |
--------------------------------------------------------------------------------
/components/App.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-undef */
2 | import React from "react";
3 | import PropTypes from "prop-types";
4 | import {Redirect} from "react-router-dom";
5 | import moment from "moment";
6 |
7 | import Navbar from "./Navbar.jsx";
8 | import Sidebar from "./Sidebar.jsx";
9 | import ThumbnailRow from "./ThumbnailRow.jsx";
10 |
11 | class App extends React.Component {
12 | constructor(props) {
13 | super(props);
14 | this.state = {data: {}, redirect: false, doge: false};
15 | }
16 |
17 | componentDidMount() {
18 | $.post("http://localhost:8000/api/feed", {
19 | token: localStorage.getItem("token")
20 | }, (data) => {
21 | if (!data.success)
22 | this.setState({redirect: true});
23 | else {
24 | this.setState({data: data.details});
25 |
26 | if (Object.keys(this.state.data).length === 0)
27 | this.setState({doge: true});
28 | }
29 | });
30 | }
31 |
32 | render() {
33 | if (this.state.doge)
34 | return ;
35 |
36 | if (this.state.redirect)
37 | return ;
38 |
39 | /*
40 | The server returns the data of the videos for the feed, grouped by username. Thus,
41 | this.state.data is an object, whose keys are usernames that the user has subscribed
42 | to.
43 |
44 | Each value corresponding to the username keys in this.state.data is an array, which
45 | is all the videos that that user (the one that the current user has subscribed *to*)
46 | has uploaded. So we iterate over these keys, which gives us all these arrays, and
47 | we gotta convert these arrays to ThumbnailRow components. We need to use
48 | Array.prototype.map for this, which does a wonderful job for us.
49 |
50 | If this is confusing to you, do a console.log for both obj and this.state.data and
51 | you'll see how this works.
52 | */
53 | let rows = (!this.state.data) ?
: Object.keys(this.state.data).map((val, index) => {
54 | if (index > 3) return null;
55 |
56 | let obj = this.state.data[val];
57 |
58 | return (
59 | {
64 | return {
65 | url: `/watch/${video.video_id}`,
66 | img: video.thumbnail,
67 | title: video.title,
68 | views: video.views,
69 | channel: {
70 | title: video.username,
71 | url: `/profile/${video.username}`
72 | },
73 | date: moment(new Date(video.upload_date)).fromNow()
74 | };
75 | })
76 | }
77 | }
78 | />
79 | );
80 | });
81 |
82 | return (
83 |
84 |
85 |
86 |
87 | {rows}
88 |
89 |
90 | );
91 | }
92 | }
93 |
94 | App.propTypes = {
95 | user: PropTypes.object,
96 | toggleLogin: PropTypes.func.isRequired
97 | };
98 |
99 | export default App;
100 |
--------------------------------------------------------------------------------
/components/Register.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-undef */
2 | import React from "react";
3 | import {Redirect} from "react-router-dom";
4 | import PropTypes from "prop-types";
5 |
6 | import Navbar from "./Navbar.jsx";
7 |
8 | class Register extends React.Component {
9 | /*
10 | props:
11 | toggleLogin: Function that is called when user is done registering
12 | user: User details
13 | */
14 | constructor(props) {
15 | super(props);
16 | this.state = {loggedIn: (this.props.user ? true : false)};
17 | this.click = this.click.bind(this);
18 | }
19 |
20 | click() {
21 | let name = $("#name").val();
22 | let username = $("#username").val();
23 | let password = $("#password").val();
24 |
25 | $.post("http://localhost:8000/api/register", {
26 | username,
27 | password,
28 | name,
29 | }, (data) => {
30 | if (!data.success)
31 | $("#message").html(`${data.message} `);
32 | else {
33 | $("#message").html(`${data.message} `);
34 | this.props.toggleLogin({
35 | name,
36 | username,
37 | dp: null
38 | });
39 |
40 | localStorage.setItem("token", data.token);
41 | this.setState({loggedIn: true});
42 | }
43 | });
44 | }
45 |
46 | render() {
47 | if (this.state.loggedIn)
48 | return (
49 |
50 | );
51 | return (
52 |
89 | );
90 | }
91 | }
92 |
93 | Register.propTypes = {
94 | toggleLogin: PropTypes.func.isRequired,
95 | user: PropTypes.object
96 | };
97 |
98 | export default Register;
99 |
--------------------------------------------------------------------------------
/components/Login.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-undef */
2 | import React from "react";
3 | import {Redirect, Link} from "react-router-dom";
4 | import PropTypes from "prop-types";
5 |
6 | import Navbar from "./Navbar.jsx";
7 |
8 | class Login extends React.Component {
9 | /*
10 | props:
11 | toggleLogin: Function called when user logs in,
12 | user: User details
13 | */
14 | constructor(props) {
15 | super(props);
16 | this.state = {loggedIn: (this.props.user ? true : false)};
17 | this.click = this.click.bind(this);
18 | }
19 |
20 | click() {
21 | $.post("http://localhost:8000/api/authenticate", {
22 | username: $("#username").val(),
23 | password: $("#password").val()
24 | }, (data) => {
25 | if (!data.success)
26 | $("#message").html(`${data.message} `);
27 | else {
28 | $("#message").html(`${data.message} `);
29 |
30 | localStorage.setItem("token", data.token);
31 | this.props.toggleLogin(data.user);
32 | this.setState({loggedIn: true});
33 | }
34 | });
35 | }
36 |
37 | render() {
38 | if (this.state.loggedIn)
39 | return (
40 |
41 | );
42 | return (
43 |
87 | );
88 | }
89 | }
90 |
91 | Login.propTypes = {
92 | toggleLogin: PropTypes.func.isRequired,
93 | user: PropTypes.object
94 | };
95 |
96 | export default Login;
97 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Video Sharing Website [](https://nodesecurity.io/orgs/dbms-project/projects/6c5f6935-88ec-4d0f-8c05-b30ebb4460f5)
2 | A simple video sharing website, based on Material design, built with React, React-Router v4, Node.js and Express.js
3 |
4 | # Table of Contents
5 | * [Setup](#setup)
6 | * [Package Manager](#package-manager)
7 | * [Front End Libraries](#front-end-libraries)
8 | * [Frameworks](#frameworks)
9 | * [Routing](#routing)
10 | * [Database](#database)
11 | * [Search](#search)
12 | * [Environment Variables](#environment-variables)
13 | * [Security](#security)
14 | * [React Components](#react-components)
15 | * [Style Guides](#style-guides)
16 | * [ESLint Configuration](#eslint-configuration)
17 | * [MySQL Style Guide](#mysql-style-guide)
18 | * [Documentation Guide](#documentation-guide)
19 | * [Directory Structure](#directory-structure)
20 |
21 | # Setup
22 | ## Package Manager
23 | Yarn is the package manager of choice. To use this repository, run `yarn` to install all required packages.
24 |
25 | ## Front End Libraries
26 | The code uses [jQuery](www.jquery.com) and [Materialize CSS](www.materializecss.com).
27 |
28 | ## Frameworks
29 | React is used for building the front end. The project is configured with Babel and Webpack for transpiling code to vanilla JS.
30 |
31 | Express is the web server used in the back end, which uses Node.js.
32 |
33 | ## Routing
34 | React-Router v4's `HashRouter` is used for client-side routing. The server doesn't handle dynamic requests, and only implements the API request handling.
35 |
36 | ## Database
37 | MariaDB (MySQL) is used as the database. `Creating_DB.sql` is initially run to set up the database. All database queries are separated into `db.js`. The server uses this to perform queries and get results.
38 |
39 | ## Search
40 | ElasticSearch is used for search functionality. The `search.js` file provides an abstraction to all searching functions, like indexing new documents, searching, and deleting indices.
41 |
42 | ## Environment Variables
43 | The `.env` file should contain the following variables:
44 | * `SESSION_SECRET`: Ideally a random string, used by JWTs for session management.
45 | * `DB_USER`: The username of the database user.
46 | * `DB_PWD`: The database password.
47 | * `DB_HOST`: The hostname of the database.
48 |
49 | ## Security
50 | Node Security Platform (NSP) is used to check for vulnerabilities in the package dependencies. Helmet.js is used to prevent XSS attacks.
51 |
52 | # React Components
53 | The React components used are below:
54 | * `App`: The home page component.
55 | * `Navbar`: The navbar at the top of the site
56 | * `Sidebar`: The sidebar shown in the homepage
57 | * `ThumbnailRow`: A row shown in the home page, with a set of related thumbnails. These could be grouped in various ways--by some channel the user subscribes to, most recently uploaded, etc.
58 | * `RoutesSwitch`: The top-level component rendered.
59 | * `Login`: The login page component
60 | * `Register`: The register page component
61 | * `Upload`: The page where the user uploads a video to the server
62 | * `VideoSelectCard`: A card shown in the upload page to select a video file
63 | * `VideoUploadCard`: A card shown in the upload page after a file is selected
64 | * `Feedback`: The feedback form page, including the navbar
65 | * `Trending`: The page showing the trending videos
66 | * `TrendingVideo`: A row showing details of one video in the trending page.
67 | * `PublicProfile`: The publicly visible profile page for a user.
68 | * `SearchResults`: The search results page.
69 | * `SettingsPage`: The personal profile page of a user where he can change his DP/background, delete videos.
70 | * `ConfirmDelete`: The page where the user confirms deletion of his account.
71 | * `WatchPage`: The page where the user can watch, rate, and comment on a video.
72 | * `Comments`: All comments and replies for a video.
73 | * `Doge`: Find out ;)
74 | * `NewLogin`: A more modern login experience. Uses a background video taken from [Vimeo's Project Yosemite](https://vimeo.com/projectyose).
75 |
76 | # Style Guides
77 |
78 | ## ESLint Configuration
79 | ESLint is configured for the following rules:
80 | * Double quotes preferred over single quotes
81 | * Indents are 4 spaces
82 | * Line breaks are LF
83 | * Semicolons are a must
84 | * Console statements are warnings
85 |
86 | ## MySQL Style Guide
87 | The style guide at [this link](http://www.sqlstyle.guide/) is used and followed in this project.
88 |
89 | ## Documentation Guide
90 | Functionality that is abstracted, such as in `search.js`, should have JSDoc comments for each function. Any React components used must be added in the `React Components` section above. Block-level comments are preferred, but not required if the code is trivial. For non-trivial logic, comments should be added briefly describing the working of the code.
91 |
92 | # Directory Structure
93 | All videos are uploaded to the `videos` folder. The database simply stores the paths to these videos and thumbnail images. As of now, the directory structure used is `videos///`, where the files include the video file and the thumbnail image.
94 |
95 | All profile pictures are stored in the `users` folder. The database stores the paths to these image files, which are served statically. The directory structure is `users//`.
96 |
--------------------------------------------------------------------------------
/components/PublicProfile.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-undef */
2 | import React from "react";
3 | import {Redirect} from "react-router-dom";
4 | import PropTypes from "prop-types";
5 | import chunk from "lodash.chunk";
6 | import numeral from "numeral";
7 | import moment from "moment";
8 |
9 | import Navbar from "./Navbar.jsx";
10 | import Sidebar from "./Sidebar.jsx";
11 | import ThumbnailRow from "./ThumbnailRow.jsx";
12 |
13 | class PublicProfile extends React.Component {
14 | constructor(props) {
15 | super(props);
16 | this.state = {rows: null, profile: {
17 | dp: "",
18 | name: "Unknown",
19 | subscribers: 0
20 | }, invalid: false, subscribed: false};
21 | this.toggleSubscription = this.toggleSubscription.bind(this);
22 | }
23 |
24 | componentDidMount() {
25 | $.get("http://localhost:8000/api/user/" + this.props.match.params.user, (data) => {
26 | if (data.success) {
27 | let chunks = chunk(data.data.videos, 4);
28 | let rows = chunks.map((val, i) =>
29 | {
32 | return {
33 | url: "/watch/" + video.video_id,
34 | img: video.thumbnail,
35 | title: video.title,
36 | views: numeral(video.views).format("0.0a"),
37 | channel: {
38 | title: video.username,
39 | url: "/profile/" + video.username
40 | },
41 | date: moment(new Date()).fromNow()
42 | };
43 | })
44 | }} />
45 | );
46 | this.setState({rows, profile: data.data.user[0]});
47 | }
48 | else {
49 | this.setState({invalid: true});
50 | Materialize.toast(data.message, 4000, "rounded");
51 | }
52 | });
53 | $.post("http://localhost:8000/api/check_subscription", {
54 | token: localStorage.getItem("token"),
55 | profile: this.props.match.params.user
56 | }, (data) => {
57 | if (data.subscribed)
58 | this.setState({subscribed: true});
59 | });
60 | }
61 |
62 | toggleSubscription() {
63 | if (!this.props.user)
64 | Materialize.toast("You need to be logged in to subscribe.", 2000, "rounded");
65 | else {
66 | $.post("http://localhost:8000/api/toggle_subscription", {
67 | token: localStorage.getItem("token"),
68 | profile: this.props.match.params.user
69 | }, (data) => {
70 | if (data.success)
71 | Materialize.toast("Subscription successful!", 2000, "rounded");
72 | else
73 | Materialize.toast(data.message, 2000, "rounded");
74 | });
75 | }
76 | }
77 |
78 | render() {
79 | if (this.state.invalid)
80 | return (
81 |
82 | );
83 |
84 | return (
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
{this.state.profile.name}
97 |
98 | {this.state.profile.subscribers + " subscriber" +
99 | (this.state.profile.subscribers > 1 ? " s" : "")}
100 |
101 |
102 |
103 |
109 |
110 |
111 |
112 | {this.state.rows}
113 |
114 |
115 |
116 | );
117 | }
118 | }
119 |
120 | PublicProfile.propTypes = {
121 | user: PropTypes.object,
122 | toggleLogin: PropTypes.func.isRequired,
123 | match: PropTypes.object.isRequired
124 | };
125 |
126 | export default PublicProfile;
127 |
--------------------------------------------------------------------------------
/components/Comments.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-undef */
2 | import React from "react";
3 | import PropTypes from "prop-types";
4 | import {Link} from "react-router-dom";
5 | import moment from "moment";
6 |
7 | class Comments extends React.Component {
8 | constructor(props) {
9 | super(props);
10 | this.state = {data: []};
11 | this.submitReply = this.submitReply.bind(this);
12 | }
13 |
14 | componentDidMount() {
15 | $.post("http://localhost:8000/api/comments", {id: this.props.video_id}, (data) => {
16 | if (data.success) {
17 | this.setState({data: data.data});
18 | }
19 | });
20 | }
21 |
22 | submitReply(e) {
23 | let comment_id = e.currentTarget.id.split("addReply")[1];
24 | let text = $("#reply" + comment_id).val();
25 |
26 | if (text.trim() == "") {
27 | Materialize.toast("You may not submit empty replies.", 2000, "rounded");
28 | return;
29 | }
30 |
31 | $.post("http://localhost:8000/api/reply", {
32 | comment_id,
33 | text,
34 | token: localStorage.getItem("token")
35 | }, (data) => {
36 | console.log(this.props.user);
37 | if (data.success) {
38 | let reply = {
39 | name: this.props.user.name,
40 | username: this.props.user.username,
41 | dp: this.props.user.dp,
42 | reply_date: moment(new Date().toISOString()).fromNow(),
43 | reply_text: text,
44 | comment_id
45 | };
46 | console.log(reply);
47 |
48 | let currentData = this.state.data.replies;
49 | currentData.push(reply);
50 | let newData = this.state.data;
51 | newData.replies = currentData;
52 |
53 | this.setState({data: newData});
54 | Materialize.toast("Reply added!", 2000, "rounded");
55 | $("#reply" + comment_id).val("");
56 | }
57 | });
58 | }
59 |
60 | render() {
61 | let comments = this.state.data.comments;
62 | let replies = this.state.data.replies;
63 |
64 | if (!comments) return
;
65 |
66 | let mainDivs = comments.map((val, i) => {
67 | /*
68 | First, take only the replies for the current comment, using
69 | Array.prototype.filter. Then, map each of them to the
70 | required HTML, and get this whole HTML for all the replies
71 | *of the current comment* in one variable, called replyDiv.
72 | */
73 | let replyDiv = replies.filter((rep) => {
74 | return rep.comment_id == val.comment_id;
75 | }).map((reply, j) =>
76 |
77 |
78 |
79 |
80 |
81 |
{reply.name}
82 |
83 |
84 | {moment(reply.reply_date).fromNow()}
85 |
86 |
{reply.reply_text}
87 |
88 |
89 |
90 | );
91 |
92 | /*
93 | Now, actually map each comment to HTML, and render the replies correctly
94 | using the replyDiv that we got earlier. This gives us all the comments
95 | with their replies, and we call that mainDiv.
96 | */
97 | return (
98 |
99 |
101 |
102 |
103 |
104 | {val.name}
105 |
106 |
107 | {moment(val.comment_date).fromNow()}
108 |
109 |
110 |
{val.comment}
111 |
112 | {/* Load the replies now */}
113 | {this.props.user ? (
114 |
115 |
117 |
127 |
128 | ) :
}
129 | {replyDiv}
130 |
131 |
132 | );
133 | });
134 |
135 | return (
136 |
137 | {mainDivs}
138 |
139 | );
140 | }
141 | }
142 |
143 | Comments.propTypes = {
144 | video_id: PropTypes.string.isRequired,
145 | user: PropTypes.object
146 | };
147 |
148 | export default Comments;
149 |
--------------------------------------------------------------------------------
/components/SettingsPage.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-undef */
2 | import React from "react";
3 | import {Redirect} from "react-router-dom";
4 | import PropTypes from "prop-types";
5 | import numeral from "numeral";
6 | import moment from "moment";
7 |
8 | import Navbar from "./Navbar.jsx";
9 | import Sidebar from "./Sidebar.jsx";
10 | import TrendingVideo from "./TrendingVideo.jsx";
11 |
12 | class SettingsPage extends React.Component {
13 | constructor(props) {
14 | super(props);
15 | this.state = {subscribers: 0, videos: [], redirect: false, user: null};
16 | this.deleteVideo = this.deleteVideo.bind(this);
17 | this.deleteUser = this.deleteUser.bind(this);
18 | this.setDp = this.setDp.bind(this);
19 | }
20 |
21 | componentDidMount() {
22 | $.get("http://localhost:8000/api/user/" + this.props.user.username,
23 | (data) => {
24 | if (!data.success)
25 | Materialize.toast(data.message);
26 | else {
27 | this.setState({subscribers: data.data.user[0].subscribers});
28 |
29 | let {videos, user} = data.data;
30 | this.setState({videos, user: user[0]});
31 | }
32 | }
33 | );
34 | }
35 |
36 | changeDp() {
37 | $("#dpInput").click();
38 | }
39 |
40 | setDp() {
41 | // From https://stackoverflow.com/a/20285053
42 | let file = $("#dpInput")[0].files[0];
43 | let fd = new FormData();
44 | fd.append("token", localStorage.getItem("token"));
45 | fd.append("dp", file);
46 |
47 | $.ajax({
48 | url: "http://localhost:8000/api/change_dp",
49 | method: "POST",
50 | processData: false,
51 | contentType: false,
52 | dataType: "json",
53 | data: fd,
54 | success: (data) => {
55 | if (data.success) {
56 | Materialize.toast("DP successfully changed!", 3000, "rounded");
57 | $("#dp").attr("src", file);
58 | } else {
59 | Materialize.toast(data.message, 3000, "rounded");
60 | }
61 | }
62 | });
63 | }
64 |
65 | deleteVideo(e) {
66 | let id = e.currentTarget.id;
67 | $.ajax({
68 | url: "http://localhost:8000/api/video/" + id,
69 | type: "DELETE",
70 | contentType: "application/json",
71 | data: JSON.stringify({token: localStorage.getItem("token")}),
72 | success: (data) => {
73 | if (data.success) {
74 | Materialize.toast("Video deleted successfully!", 2500, "rounded");
75 |
76 | let newVideos = [];
77 | for (let video of this.state.videos) {
78 | if (video.video_id != id)
79 | newVideos.push(video);
80 | }
81 |
82 | this.setState({videos: newVideos});
83 | } else {
84 | Materialize.toast("Couldn't delete video", 2000, "rounded");
85 | }
86 | }
87 | });
88 | }
89 |
90 | deleteUser() {
91 | // Redirect user to confirmation page and ask for password.
92 | this.setState({redirect: true});
93 | }
94 |
95 | render() {
96 | if (this.state.redirect)
97 | return ;
98 |
99 | let rows = this.state.videos.map((vid, i) =>
100 |
101 |
102 |
109 |
110 |
116 |
117 | );
118 |
119 | if (!this.props.user)
120 | return (
121 |
122 | );
123 |
124 | return (
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 |
134 |
135 |
136 |
{this.props.user.name}
137 |
138 | {this.state.subscribers + (this.state.subscribers > 1 ? " subscribers" : " subscriber")}
139 |
140 |
141 |
142 |
143 |
144 |
150 |
151 |
Your Videos
152 |
153 | {rows}
154 |
155 |
156 |
163 |
164 |
165 |
166 | );
167 | }
168 | }
169 |
170 | SettingsPage.propTypes = {
171 | user: PropTypes.object,
172 | toggleLogin: PropTypes.func.isRequired
173 | };
174 |
175 | export default SettingsPage;
176 |
--------------------------------------------------------------------------------
/components/WatchPage.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-undef */
2 | import React from "react";
3 | import PropTypes from "prop-types";
4 | import {Redirect, Link} from "react-router-dom";
5 | import numeral from "numeral";
6 |
7 | import Navbar from "./Navbar.jsx";
8 | import Comments from "./Comments.jsx";
9 | import TrendingVideo from "./TrendingVideo.jsx";
10 |
11 | class WatchPage extends React.Component {
12 | constructor(props) {
13 | super(props);
14 | this.state = {video: {}, error: false, recommendations: []};
15 | this.submitComment = this.submitComment.bind(this);
16 | this.submitUpvote = this.submitUpvote.bind(this);
17 | this.submitDownvote = this.submitDownvote.bind(this);
18 | this.addView = this.addView.bind(this);
19 | }
20 |
21 | componentDidMount() {
22 | let id = this.props.match.params.id;
23 | $.post("http://localhost:8000/api/video", {id, token: localStorage.getItem("token")}, (data) => {
24 | if (!data.success) {
25 | Materialize.toast("Couldn't load the video.", 2500, "rounded");
26 | this.setState({error: true});
27 | } else {
28 | this.setState({video: data.details});
29 | let userRating = data.details.user_rating;
30 | if (userRating == 1)
31 | $("#upvote").css("color", "green");
32 | else if (userRating == -1)
33 | $("#downvote").css("color", "green");
34 |
35 | $.get("http://localhost:8000/api/user/" + data.details.username, (result) => {
36 | this.setState({recommendations: result.data.videos});
37 | });
38 | }
39 | });
40 |
41 | /*
42 | Use the Page Visibility API to pause the video when the tab is not being viewed.
43 | This prevents abuse of video views, and ensures users don't need to manually
44 | pause the video in legitimate cases. Code taken from:
45 | https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API
46 | */
47 | let hidden, visibilityChange;
48 | let videoElement = document.getElementById("video");
49 | if (typeof document.hidden !== "undefined") { // Opera 12.10 and Firefox 18 and later support
50 | hidden = "hidden";
51 | visibilityChange = "visibilitychange";
52 | } else if (typeof document.msHidden !== "undefined") {
53 | hidden = "msHidden";
54 | visibilityChange = "msvisibilitychange";
55 | } else if (typeof document.webkitHidden !== "undefined") {
56 | hidden = "webkitHidden";
57 | visibilityChange = "webkitvisibilitychange";
58 | }
59 | document.addEventListener(visibilityChange, () => {
60 | if (document[hidden])
61 | videoElement.pause();
62 | else
63 | videoElement.play();
64 | }, false);
65 | }
66 |
67 | submitUpvote() {
68 | let userRating = this.state.video.user_rating;
69 | let details = this.state.video;
70 | if (userRating == 1) {
71 | // Remove the upvote
72 | $.post("http://localhost:8000/api/votes", {
73 | token: localStorage.getItem("token"),
74 | video_id: details.video_id,
75 | action: "remove",
76 | }, (data) => {
77 | if (data.success) {
78 | $("#upvote").css("color", "black");
79 | details.user_rating = 0;
80 | details.upvotes -= 1;
81 | } else {
82 | Materialize.toast("You need to be logged in to vote.", 2000, "rounded");
83 | }
84 | });
85 | } else if (userRating == -1) {
86 | // Remote downvote and add upvote
87 | $.post("http://localhost:8000/api/votes", {
88 | token: localStorage.getItem("token"),
89 | video_id: details.video_id,
90 | action: "update"
91 | }, (data) => {
92 | if (data.success) {
93 | $("#upvote").css("color", "green");
94 | $("#downvote").css("color", "black");
95 | details.user_rating = 1;
96 | details.upvotes += 1;
97 | details.downvotes -= 1;
98 | } else {
99 | Materialize.toast("You need to be logged in to vote.", 2000, "rounded");
100 | }
101 | });
102 | } else {
103 | // Add a new upvote
104 | $.post("http://localhost:8000/api/votes", {
105 | token: localStorage.getItem("token"),
106 | video_id: details.video_id,
107 | action: "add",
108 | vote: 1
109 | }, (data) => {
110 | if (data.success) {
111 | $("#upvote").css("color", "green");
112 | details.upvotes += 1;
113 | details.user_rating = 1;
114 | } else {
115 | Materialize.toast("You need to be logged in to vote.", 2000, "rounded");
116 | }
117 | });
118 | }
119 | this.setState({video: details});
120 | }
121 |
122 | submitDownvote() {
123 | let userRating = this.state.video.user_rating;
124 | let details = this.state.video;
125 | if (userRating == 1) {
126 | // Remove the upvote and add downvote
127 | $.post("http://localhost:8000/api/votes", {
128 | token: localStorage.getItem("token"),
129 | video_id: details.video_id,
130 | action: "update",
131 | }, (data) => {
132 | if (data.success) {
133 | $("#upvote").css("color", "black");
134 | $("#downvote").css("color", "green");
135 | details.user_rating = -1;
136 | details.upvotes -= 1;
137 | details.downvotes += 1;
138 | } else {
139 | Materialize.toast("You need to be logged in to vote.", 2000, "rounded");
140 | }
141 | });
142 | } else if (userRating == -1) {
143 | // Remote downvote
144 | $.post("http://localhost:8000/api/votes", {
145 | token: localStorage.getItem("token"),
146 | video_id: details.video_id,
147 | action: "remove"
148 | }, (data) => {
149 | if (data.success) {
150 | $("#downvote").css("color", "black");
151 | details.user_rating = 0;
152 | details.downvotes -= 1;
153 | } else {
154 | Materialize.toast("You need to be logged in to vote.", 2000, "rounded");
155 | }
156 | });
157 | } else {
158 | // Add a new downvote
159 | $.post("http://localhost:8000/api/votes", {
160 | token: localStorage.getItem("token"),
161 | video_id: details.video_id,
162 | action: "add",
163 | vote: -1
164 | }, (data) => {
165 | if (data.success) {
166 | $("#downvote").css("color", "green");
167 | details.downvotes += 1;
168 | details.user_rating = -1;
169 | } else {
170 | Materialize.toast("You need to be logged in to vote.", 2000, "rounded");
171 | }
172 | });
173 | }
174 | this.setState({video: details});
175 | }
176 |
177 | addView() {
178 | $.post("http://localhost:8000/api/video/add_view", {
179 | video_id: this.props.match.params.id
180 | }, () => {});
181 | }
182 |
183 | submitComment() {
184 | let comment = $("textarea").val();
185 | if (comment.trim() == "") {
186 | Materialize.toast("You may not submit empty comments.", 2000, "rounded");
187 | return;
188 | }
189 |
190 | $.post("http://localhost:8000/api/comment", {
191 | video_id: this.props.match.params.id,
192 | comment,
193 | token: localStorage.getItem("token")
194 | }, (data) => {
195 | if (!data.success)
196 | Materialize.toast("Failed to submit comment.", 2000, "rounded");
197 | else {
198 | Materialize.toast("Comment successfully added!", 2000, "rounded");
199 | $("#comment").val("");
200 | }
201 | });
202 | }
203 |
204 | render() {
205 | if (this.state.error)
206 | return ;
207 |
208 | let commentBox = this.props.user ? (
209 |
210 |
211 |
213 |
214 |
215 | Add a public comment...
216 |
217 |
218 |
220 | COMMENT
221 |
222 |
223 | ) :
;
224 |
225 | let recommendDiv = this.state.recommendations ? this.state.recommendations.map((val, i) =>
226 |
227 |
231 |
232 | ) :
;
233 |
234 | return (
235 |
236 |
237 |
238 |
239 |
240 |
243 |
244 |
245 |
246 |
247 | {this.state.video.title}
248 |
249 |
251 | {this.state.video.views + " views"}
252 |
253 |
254 |
255 |
257 | thumb_up
258 |
259 |
260 | {numeral(this.state.video.upvotes).format("0a")}
261 |
262 |
264 | thumb_down
265 |
266 | {" " + numeral(this.state.video.downvotes).format("0a")}
267 |
268 |
269 |
270 |
271 |
273 |
274 |
275 |
{this.state.video.name}
276 |
277 |
Published on {new Date(this.state.video.upload_date).toDateString()}
278 |
{this.state.video.description}
279 |
280 |
281 |
282 |
Comments
283 | {commentBox}
284 |
285 |
286 |
287 |
Videos by this user
288 | {recommendDiv}
289 |
290 |
291 |
292 |
293 | );
294 | }
295 | }
296 |
297 | WatchPage.propTypes = {
298 | match: PropTypes.object,
299 | user: PropTypes.object
300 | };
301 |
302 | export default WatchPage;
303 |
--------------------------------------------------------------------------------
/server/server.js:
--------------------------------------------------------------------------------
1 | import cors from "cors";
2 | import express from "express";
3 | import path from "path";
4 | import open from "open";
5 | import compression from "compression";
6 | import bodyParser from "body-parser";
7 | import dotenv from "dotenv";
8 | import jwt from "jsonwebtoken";
9 | import formidable from "formidable";
10 | import fs from "fs";
11 | import helmet from "helmet";
12 | import numeral from "numeral";
13 |
14 | // Used for transpiling
15 | import webpack from "webpack";
16 | import config from "../webpack.config";
17 |
18 | import dbUtils from "./db";
19 | import searchUtils from "./search";
20 |
21 | const port = 8000;
22 | const app = express();
23 | const compiler = webpack(config);
24 | const illegalCharsFormat = /[!@#$%^&*()+\-=[\]{};':"\\|,.<>/?]/;
25 | dotenv.config();
26 |
27 | // gzip files
28 | app.use(helmet());
29 | app.use(compression());
30 | app.use(bodyParser.json());
31 | app.use(bodyParser({extended: true}));
32 | app.use(cors());
33 | app.use(express.static(__dirname + "/public"));
34 | app.use("/videos", express.static(__dirname + "/../videos"));
35 | app.use("/users", express.static(__dirname + "/../users"));
36 |
37 | // Use Webpack middleware
38 | app.use(require("webpack-dev-middleware")(compiler, {
39 | noInfo: true,
40 | publicPath: config.output.publicPath
41 | }));
42 |
43 | app.get("/api/trending", (req, res) => {
44 | res.writeHead(200, {"Content-Type": "application/json"});
45 |
46 | // Define trending as videos uploaded in the last 5 days, with maximum views.
47 | dbUtils.init();
48 | dbUtils.trending((err, videos) => {
49 | if (err) throw err;
50 | res.end(JSON.stringify({
51 | success: true,
52 | videos
53 | }));
54 | });
55 | });
56 |
57 | app.get("/api/user/:username", (req, res) => {
58 | res.writeHead(200, {"Content-Type": "application/json"});
59 |
60 | dbUtils.init();
61 | dbUtils.userDetails(req.params.username, (err, results) => {
62 | if (err)
63 | res.end(JSON.stringify({
64 | success: false,
65 | message: "Username does not exist."
66 | }));
67 | else
68 | res.end(JSON.stringify({
69 | success: true,
70 | data: results
71 | }));
72 | });
73 | });
74 |
75 | app.get("/*", (req, res) => {
76 | res.sendFile(path.join(__dirname, "../src/index.html"));
77 | });
78 |
79 | app.post("/api/upload", (req, res) => {
80 | res.writeHead(200, {"Content-Type": "application/json"});
81 |
82 | let form = new formidable.IncomingForm();
83 | form.parse(req, (err, fields, files) => {
84 | let {video, thumbnail} = files;
85 | let {title, desc, token} = fields;
86 | // Verify file types
87 | if (!thumbnail.type.match(/image\/.*/)) {
88 | res.end(JSON.stringify({success: false, message: "Thumbnail must be an image."}));
89 | return;
90 | }
91 | if (!video.type.match(/video\/.*/)) {
92 | res.end(JSON.stringify({success: false, message: "Upload must be a video type."}));
93 | return;
94 | }
95 | if (!title) {
96 | res.end(JSON.stringify({success: false, message: "Title cannot be empty."}));
97 | return;
98 | }
99 | if (token) {
100 | jwt.verify(token, process.env.SESSION_SECRET, (e_, decoded) => {
101 | if (e_) {
102 | res.end(JSON.stringify({success: false, message: "No token provided."}));
103 | return;
104 | }
105 | let username = decoded.username;
106 |
107 | // First check if a video with the same details has already been uploaded.
108 | if (fs.exists(`./videos/${username}/${title}`)) {
109 | res.end(JSON.stringify({
110 | success: false,
111 | message: "You have uploaded another video with the same details."
112 | }));
113 | return;
114 | }
115 |
116 | if (!fs.existsSync("./videos")) {
117 | fs.mkdirSync("./videos");
118 | fs.mkdirSync(`./videos/${username}`);
119 | } else if (!fs.existsSync(`./videos/${username}`)) {
120 | fs.mkdirSync(`./videos/${username}`);
121 | }
122 |
123 | let path = `./videos/${username}/${title}`;
124 | fs.mkdirSync(path);
125 | fs.rename(video.path, path + `/${video.name}`, (e) => {
126 | if (e) {
127 | res.end(JSON.stringify({success: false, message: "Unknown error while saving video."}));
128 | return;
129 | }
130 |
131 | fs.rename(thumbnail.path, path + `/${thumbnail.name}`, (e) => {
132 | if (e) {
133 | res.end(JSON.stringify({success: false, message: "Unknown error while saving video."}));
134 | return;
135 | }
136 |
137 | // Write data to database.
138 | dbUtils.init();
139 | dbUtils.upload(username, title, path + `/${video.name}`,
140 | path + `/${thumbnail.name}`, new Date(), desc, (err, id) => {
141 | // Upload successful, so now add it to index.
142 | searchUtils.index("qtube", "video", {
143 | title,
144 | username,
145 | thumbnail: path + `/${thumbnail.name}`,
146 | description: desc,
147 | video_id: id.toString()
148 | });
149 |
150 | res.end(JSON.stringify({success: true, message: "Successfully uploaded!"}));
151 | });
152 | });
153 | });
154 | });
155 | }
156 | });
157 | });
158 |
159 | app.post("/api/authenticate", (req, res) => {
160 | res.writeHead(200, {"Content-Type": "application/json"});
161 | let {username, password} = req.body;
162 |
163 | if (!username || !password)
164 | res.end(JSON.stringify({
165 | success: false,
166 | message: "Fields cannot be empty"
167 | }));
168 | else {
169 | dbUtils.init();
170 | dbUtils.authenticate(username, password, (err, authResult) => {
171 | if (err) throw err;
172 |
173 | if (authResult.success) {
174 | let user = {
175 | username: authResult.results.username,
176 | name: authResult.results.name,
177 | dp: authResult.results.dp
178 | };
179 | let token = jwt.sign(user, process.env.SESSION_SECRET, {
180 | expiresIn: "1 day"
181 | });
182 | res.end(JSON.stringify({
183 | success: true,
184 | message: "Logged in successfully!",
185 | user,
186 | token
187 | }));
188 | } else
189 | res.end(JSON.stringify({
190 | success: false,
191 | message: authResult.message
192 | }));
193 | });
194 | }
195 | });
196 |
197 | app.post("/api/register", (req, res) => {
198 | res.writeHead(200, {"Content-Type": "application/json"});
199 | let {username, password, name} = req.body;
200 | if (!username || !password || !name) {
201 | res.end(JSON.stringify({
202 | success: false,
203 | message: "Fields cannot be empty"
204 | }));
205 | return;
206 | }
207 | if (illegalCharsFormat.test(username) ||
208 | illegalCharsFormat.test(name)) {
209 | res.end(JSON.stringify({
210 | success: false,
211 | message: "Special characters aren't allowed in usernames and names."
212 | }));
213 | return;
214 | }
215 | if (username.includes(" ")) {
216 | res.end(JSON.stringify({
217 | success: false,
218 | message: "Spaces aren't allowed in usernames."
219 | }));
220 | return;
221 | }
222 |
223 | dbUtils.init();
224 | dbUtils.register(username, password, name, (e, regResult) => {
225 | if (e) throw e;
226 |
227 | if (regResult.success) {
228 | let user = {
229 | username,
230 | name,
231 | dp: null
232 | };
233 | let token = jwt.sign(user, process.env.SESSION_SECRET, {
234 | expiresIn: "1 day"
235 | });
236 |
237 | // Registration successful, add to index and then end response.
238 | searchUtils.index("qtube", "user", {
239 | name,
240 | username
241 | });
242 |
243 | res.end(JSON.stringify({
244 | success: regResult.success,
245 | message: regResult.message,
246 | token
247 | }));
248 | } else
249 | res.end(JSON.stringify(regResult));
250 | });
251 | });
252 |
253 | app.post("/api/feedback", (req, res) => {
254 | res.writeHead(200, {"Content-Type": "application/json"});
255 | let {token, feedback} = req.body;
256 |
257 | jwt.verify(token, process.env.SESSION_SECRET, (err, decoded) => {
258 | dbUtils.init();
259 | dbUtils.feedback(decoded.username, feedback, (e, result) => {
260 | if (e) throw e;
261 |
262 | res.end(JSON.stringify(result));
263 | });
264 | });
265 | });
266 |
267 | app.post("/api/whoami", (req, res) => {
268 | res.writeHead(200, {"Content-Type": "application/json"});
269 | let {token} = req.body;
270 | jwt.verify(token, process.env.SESSION_SECRET, (err, decoded) => {
271 | if (err)
272 | res.end(JSON.stringify({
273 | success: false
274 | }));
275 | else
276 | res.end(JSON.stringify({
277 | success: true,
278 | user: decoded
279 | }));
280 | });
281 | });
282 |
283 | app.post("/api/search", (req, res) => {
284 | res.writeHead(200, {"Content-Type": "application/json"});
285 | let queryString = req.body.query;
286 | let body = {
287 | size: 10, // Number of results
288 | from: 0, // Start index of results returned
289 | query: {
290 | multi_match: {
291 | query: queryString,
292 | fields: ["username", "name", "description", "title"],
293 | minimum_should_match: 1,
294 | fuzziness: 2
295 | }
296 | }
297 | };
298 | searchUtils.search("qtube", body).then(results => {
299 | let response = {
300 | time: results.took,
301 | results: results.hits.hits
302 | };
303 |
304 | res.end(JSON.stringify(response));
305 | });
306 | });
307 |
308 | app.post("/api/video/details", (req, res) => {
309 | res.writeHead(200, {"Content-Type": "application/json"});
310 | let {id} = req.body;
311 |
312 | dbUtils.init();
313 | dbUtils.details(id, (err, results) => {
314 | if (err)
315 | res.end(JSON.stringify({
316 | success: false
317 | }));
318 | else
319 | res.end(JSON.stringify({
320 | success: true,
321 | age: results[0].age,
322 | views: numeral(results[0].views).format("0.0a")
323 | }));
324 | });
325 | });
326 |
327 | app.delete("/api/video/:id", (req, res) => {
328 | res.writeHead(200, {"Content-Type": "application/json"});
329 |
330 | jwt.verify(req.body.token, process.env.SESSION_SECRET, (err, decoded) => {
331 | if (err)
332 | res.end({success: false});
333 | else {
334 | dbUtils.init();
335 | dbUtils.deleteVideo(decoded.username, req.params.id, (err) => {
336 | if (err)
337 | res.end(JSON.stringify({
338 | success: false,
339 | message: "Couldn't delete the video."
340 | }));
341 | else {
342 | // Now delete the video from search index
343 | searchUtils.deleteDoc("qtube", "video", req.params.id, (e) => {
344 | if (e) throw e;
345 | res.end(JSON.stringify({success: true}));
346 | });
347 | }
348 | });
349 | }
350 | });
351 | });
352 |
353 | app.delete("/api/user", (req, res) => {
354 | res.writeHead(200, {"Content-Type": "application/json"});
355 |
356 | let {token, pwd} = req.body;
357 | /* Decode the user from the token, and verify the password. If it's right,
358 | delete the user account, as well as all the user's content. */
359 | jwt.verify(token, process.env.SESSION_SECRET, (err, decoded) => {
360 | if (err) throw err;
361 |
362 | let {username} = decoded;
363 | dbUtils.authenticate(username, pwd, (e, authResult) => {
364 | if (e) throw e;
365 | if (authResult.success) {
366 | dbUtils.init();
367 | dbUtils.deleteUser(username, (e_) => {
368 | if (e_) throw e_;
369 |
370 | res.end(JSON.stringify({success: true}));
371 | });
372 | } else
373 | res.end(JSON.stringify({
374 | success: false,
375 | message: "Incorrect password"
376 | }));
377 | });
378 | });
379 | });
380 |
381 | app.post("/api/video", (req, res) => {
382 | res.writeHead(200, {"Content-Type": "application/json"});
383 |
384 | let {id, token} = req.body;
385 | let username;
386 |
387 | dbUtils.init();
388 | jwt.verify(token, process.env.SESSION_SECRET, (err, decoded) => {
389 | if (err) {
390 | // User can still watch the video.
391 | username = null;
392 | } else
393 | username = decoded.username;
394 | });
395 | dbUtils.videoDetails(username, id, (err, results) => {
396 | if (err)
397 | throw err;
398 | res.end(JSON.stringify({
399 | success: true,
400 | details: results
401 | }));
402 | });
403 | });
404 |
405 | app.post("/api/comments", (req, res) => {
406 | res.writeHead(200, {"Content-Type": "application/json"});
407 |
408 | let {id} = req.body;
409 | dbUtils.init();
410 | dbUtils.comments(id, (err, result) => {
411 | if (err)
412 | res.end(JSON.stringify({success: false}));
413 | else
414 | res.end(JSON.stringify({success: true, data: result}));
415 | });
416 | });
417 |
418 | app.post("/api/comment", (req, res) => {
419 | res.writeHead(200, {"Content-Type": "application/json"});
420 | let {token, comment, video_id} = req.body;
421 |
422 | jwt.verify(token, process.env.SESSION_SECRET, (err, decoded) => {
423 | dbUtils.init();
424 | dbUtils.addComment(video_id, comment, decoded.username, (e) => {
425 | if (e)
426 | res.end(JSON.stringify({success: false}));
427 | else
428 | res.end(JSON.stringify({success: true}));
429 | });
430 | });
431 | });
432 |
433 | app.post("/api/votes", (req, res) => {
434 | res.writeHead(200, {"Content-Type": "application/json"});
435 | let {token, action, video_id, vote} = req.body;
436 |
437 | jwt.verify(token, process.env.SESSION_SECRET, (err, decoded) => {
438 | if (err) {
439 | res.end(JSON.stringify({success: false}));
440 | return;
441 | }
442 |
443 | let {username} = decoded;
444 | dbUtils.init();
445 | switch (action) {
446 | case "add":
447 | dbUtils.addVote(video_id, username, vote, (err) => {
448 | if (err) {
449 | res.end(JSON.stringify({success: false}));
450 | return;
451 | }
452 | });
453 | break;
454 |
455 | case "update":
456 | dbUtils.swapVote(video_id, username, (err) => {
457 | if (err) {
458 | res.end(JSON.stringify({success: false}));
459 | return;
460 | }
461 | });
462 | break;
463 |
464 | case "remove":
465 | dbUtils.removeVote(video_id, username, (err) => {
466 | if (err) {
467 | res.end(JSON.stringify({success: false}));
468 | return;
469 | }
470 | });
471 | break;
472 | }
473 | res.end(JSON.stringify({success: true}));
474 | });
475 | });
476 |
477 | app.post("/api/toggle_subscription", (req, res) => {
478 | res.writeHead(200, {"Content-Type": "application/json"});
479 |
480 | let {token, profile} = req.body;
481 | jwt.verify(token, process.env.SESSION_SECRET, (err, decoded) => {
482 | if (err) {
483 | res.end(JSON.stringify({
484 | success: false,
485 | message: err.message
486 | }));
487 | return;
488 | }
489 |
490 | let {username} = decoded;
491 | dbUtils.init();
492 | dbUtils.toggleSubscription(profile, username, (e) => {
493 | if (e) {
494 | res.end(JSON.stringify({
495 | success: false,
496 | message: err
497 | }));
498 | return;
499 | } else
500 | res.end(JSON.stringify({success: true}));
501 | });
502 | });
503 | });
504 |
505 | app.post("/api/reply", (req, res) => {
506 | res.writeHead(200, {"Content-Type": "application/json"});
507 |
508 | let {comment_id, text, token} = req.body;
509 | jwt.verify(token, process.env.SESSION_SECRET, (err, decoded) => {
510 | if (err) {
511 | res.end(JSON.stringify({success: false}));
512 | return;
513 | }
514 |
515 | let {username} = decoded;
516 | dbUtils.init();
517 | dbUtils.addReply(comment_id, username, text, (e) => {
518 | if (e)
519 | throw e;
520 |
521 | res.end(JSON.stringify({success: true}));
522 | });
523 | });
524 | });
525 |
526 | app.post("/api/video/add_view", (req, res) => {
527 | res.writeHead(200, {"Content-Type": "application/json"});
528 |
529 | let {video_id} = req.body;
530 | dbUtils.init();
531 | dbUtils.incrementViews(video_id, (err) => {
532 | if (err) throw err;
533 |
534 | res.end(JSON.stringify({success: true}));
535 | });
536 | });
537 |
538 | app.post("/api/change_dp", (req, res) => {
539 | res.writeHead(200, {"Content-Type": "application/json"});
540 |
541 | let form = new formidable.IncomingForm();
542 | form.parse(req, (err, fields, files) => {
543 | if (err) {
544 | res.end(JSON.stringify({success: false, message: "Unknown error occurred"}));
545 | return;
546 | }
547 |
548 | let {dp} = files;
549 | let {token} = fields;
550 | if (!dp.type.match(/image\/.*/)) {
551 | res.end(JSON.stringify({success: false, message: "Thumbnail must be an image."}));
552 | return;
553 | }
554 |
555 | jwt.verify(token, process.env.SESSION_SECRET, (err, decoded) => {
556 | if (err) {
557 | res.end(JSON.stringify({success: false}));
558 | return;
559 | }
560 | let {username} = decoded;
561 |
562 | if (!fs.existsSync("./users"))
563 | fs.mkdirSync("./users");
564 | if (!fs.existsSync(`./users/${username}`))
565 | fs.mkdirSync(`./users/${username}`);
566 |
567 | fs.rename(dp.path, `./users/${username}/${dp.name}`, (e) => {
568 | if (e) {
569 | res.end(JSON.stringify({success: false, message: "Failed to upload video."}));
570 | return;
571 | }
572 |
573 | dbUtils.init();
574 | dbUtils.updateDp(username, `./users/${username}/${dp.name}`, (e) => {
575 | if (e) {
576 | res.end(JSON.stringify({success: false, message: "Failed to save video."}));
577 | return;
578 | }
579 |
580 | res.end(JSON.stringify({success: true}));
581 | });
582 | });
583 | });
584 | });
585 | });
586 |
587 | app.post("/api/feed", (req, res) => {
588 | // Just use res.json instead in this
589 | let {token} = req.body;
590 |
591 | jwt.verify(token, process.env.SESSION_SECRET, (err, decoded) => {
592 | if (err) {
593 | res.json({success: false});
594 | return;
595 | }
596 |
597 | let {username} = decoded;
598 | dbUtils.init();
599 | dbUtils.getSubscribedVideos(username, (e, results) => {
600 | if (e)
601 | throw e;
602 |
603 | res.json({success: true, details: results});
604 | });
605 | });
606 | });
607 |
608 | app.listen(port, (err) => {
609 | if (err) throw err;
610 | open("http://localhost:" + port);
611 | });
612 |
--------------------------------------------------------------------------------
/server/db.js:
--------------------------------------------------------------------------------
1 | // Database functions module
2 | import mysql from "mysql";
3 | import dotenv from "dotenv";
4 | import bcrypt from "bcrypt";
5 | import search from "./search.js";
6 | import groupBy from "lodash.groupby";
7 | import { Object } from "core-js/library/web/timers";
8 |
9 | let connection;
10 |
11 | /**
12 | * Establishes a connection to the database.
13 | */
14 | exports.init = () => {
15 | dotenv.config();
16 | connection = mysql.createConnection({
17 | host: process.env.DB_HOST,
18 | user: process.env.DB_USER,
19 | password: process.env.DB_PWD,
20 | database: "video_sharing"
21 | });
22 | connection.connect();
23 | };
24 |
25 | /**
26 | * Terminates connection to the database.
27 | */
28 | exports.terminate = () => {
29 | connection.end();
30 | };
31 |
32 | /**
33 | * Checks if the user has entered the right set of authentication details.
34 | * @param {string} username - The username entered
35 | * @param {string} pwd - The plaintext password entered
36 | * @param {Function} func - The callback function
37 | */
38 | exports.authenticate = (username, pwd, func) => {
39 | connection.query("SELECT * FROM users WHERE BINARY username = ?", [username], (err, results) => {
40 | if (err) {
41 | func(err);
42 | return;
43 | }
44 |
45 | if (!results.length) {
46 | func(null, {
47 | success: false,
48 | message: "User does not exist."
49 | });
50 | return;
51 | }
52 |
53 | bcrypt.compare(pwd, results[0].pwd, (e, res) => {
54 | if (e) throw e;
55 |
56 | if (!res)
57 | func(null, {
58 | success: false,
59 | message: "Username and password do not match."
60 | });
61 |
62 | func(null, {
63 | success: true,
64 | results: results[0]
65 | });
66 | });
67 | });
68 | };
69 |
70 | /**
71 | * Registers a user by adding the details to the users table. Also adds user to
72 | * subscriptions table, since by default, every user subscribes to him/herself.
73 | * @param {string} username - The username of the new user
74 | * @param {string} pwd - The plaintext password of the user. This will be hashed.
75 | * @param {string} name - The user's name
76 | * @param {Function} func - The callback function
77 | */
78 | exports.register = (username, pwd, name, func) => {
79 | connection.query("SELECT * FROM users WHERE BINARY username = ?", [username], (err, results) => {
80 | if (err) {
81 | func(err);
82 | return;
83 | }
84 |
85 | // First check if user exists already.
86 | if (results.length) {
87 | func(null, {
88 | success: false,
89 | message: "Username already exists."
90 | });
91 | return;
92 | }
93 | });
94 |
95 | // Gotta hash the password first!
96 | bcrypt.hash(pwd, 10, (e_, hash) => {
97 | if (e_) {
98 | func(e_);
99 | return;
100 | }
101 |
102 | connection.query("INSERT INTO users (name, username, pwd) VALUES (?, ?, ?)", [name, username, hash], (err) => {
103 | if (err) {
104 | // Can't simply throw an error here, return an error message instead.
105 | func(null, {
106 | success: false,
107 | message: "Unknown error occurred, try again."
108 | });
109 | return;
110 | }
111 |
112 | func(null, {
113 | success: true,
114 | message: "Successfully registered!",
115 | username
116 | });
117 | });
118 | });
119 | };
120 |
121 | /**
122 | * Inserts the parameters of the video into the videos table. The actual
123 | * uploading must be done by the server.
124 | * @param {string} username - The uploader username
125 | * @param {string} title - The video title
126 | * @param {string} path - The path to the video file in the disk
127 | * @param {string} thumbnail - The path to the thumbnail file on disk
128 | * @param {Date} date - Date of uploading
129 | * @param {string} desc - Video description
130 | * @param {Function} func - The callback function
131 | */
132 | exports.upload = (username, title, path, thumbnail, date, desc, func) => {
133 | connection.query("INSERT INTO videos (username, title, video_path,\
134 | thumbnail, upload_date, description) VALUES (?, ?, ?, ?, ?, ?)",
135 | [username, title, path, thumbnail, date, desc], (err, results) => {
136 | if (err) throw err;
137 |
138 | func(null, results.insertId); // func takes no arguments, a call indicates success.
139 | }
140 | );
141 | };
142 |
143 | /**
144 | * Submits feedback by adding to the feedback table
145 | * @param {string} username - The username of the person submitting the feedback
146 | * @param {string} feedback - The submitted feedback
147 | * @param {Function} func - The callback function
148 | */
149 | exports.feedback = (username, feedback, func) => {
150 | connection.query("SELECT * FROM feedback WHERE BINARY username = ?", [username], (err, results) => {
151 | if (err) {
152 | func(err);
153 | return;
154 | }
155 |
156 | if (results.length) {
157 | func(null, {
158 | success: false,
159 | message: "You have already submitted feedback!"
160 | });
161 | return;
162 | }
163 |
164 | connection.query("INSERT INTO feedback VALUES (?, ?)", [username, feedback], (err) => {
165 | if (err) {
166 | func(err);
167 | return;
168 | }
169 |
170 | func(null, {
171 | success: true,
172 | message: "Thanks for your feedback!"
173 | });
174 | });
175 | });
176 | };
177 |
178 | /**
179 | * Gives a list of trending videos
180 | * @param {Function} func - The callback function
181 | */
182 | exports.trending = (func) => {
183 | let sql = "SELECT *, DATEDIFF(?, upload_date) AS age \
184 | FROM videos \
185 | WHERE DATEDIFF(?, upload_date) < 6";
186 | let date = new Date().toISOString();
187 | connection.query(sql, [date, date], (err, results) => {
188 | if (err) {
189 | func(err);
190 | return;
191 | }
192 | func(null, results);
193 | });
194 | };
195 |
196 | /**
197 | * Returns the views and age of a video.
198 | * @param {number} id - The video id in the database
199 | * @param {Function} func - The callback function
200 | */
201 | exports.details = (id, func) => {
202 | let sql = "SELECT COUNT(video_views.username) AS views, DATEDIFF(?, upload_date) AS age \
203 | FROM videos, video_views \
204 | WHERE videos.video_id = ? \
205 | AND video_views.video_id = ?";
206 | connection.query(sql, [new Date().toISOString(), id, id], (err, results) => {
207 | if (err) {
208 | func(err);
209 | return;
210 | }
211 | func(null, results);
212 | });
213 | };
214 |
215 | /**
216 | * Returns details of user with given username
217 | * @param {string} username - The username whose details are required.
218 | * @param {Function} func - The callback function
219 | */
220 | exports.userDetails = (username, func) => {
221 | let sql = "SELECT u.name, u.username, u.dp, COUNT(*) AS subscribers \
222 | FROM users AS u \
223 | NATURAL JOIN subscriptions AS s \
224 | WHERE BINARY u.username = ?";
225 | connection.query(sql, [username], (err, results) => {
226 | if (err) {
227 | func(err);
228 | return;
229 | }
230 |
231 | if (!results.length)
232 | func(new Error("Username does not exist."));
233 |
234 | sql = "SELECT * \
235 | FROM videos \
236 | WHERE username = ?";
237 | connection.query(sql, [username], (e, r) => {
238 | if (err)
239 | func(err);
240 |
241 | func(null, {
242 | user: results,
243 | videos: r
244 | });
245 | });
246 | });
247 | };
248 |
249 | /**
250 | * Delete a video with the given video_id. Make sure the video was uploaded by
251 | * the user with the given username.
252 | * @param {string} username - The user's username
253 | * @param {number} id - The video_id to delete
254 | * @param {Function} func - The callback function, taking only one argument.
255 | */
256 | exports.deleteVideo = (username, id, func) => {
257 | // First verify if the username is right
258 | let sql = "SELECT BINARY username = ? AS valid \
259 | FROM videos \
260 | WHERE video_id = ?";
261 | connection.query(sql, [username, id], (err, results) => {
262 | if (err) {
263 | func(err);
264 | return;
265 | }
266 |
267 | if (results[0].valid != "1")
268 | func(new Error("Authorization failed."));
269 | else {
270 | sql = "DELETE FROM videos WHERE video_id = ?";
271 | connection.query(sql, [id], (err) => {
272 | if (err) {
273 | func(err);
274 | return;
275 | }
276 | search.deleteDoc("qtube", "video", id, (err) => {
277 | if(err) {
278 | func(err);
279 | return;
280 | }
281 | });
282 |
283 | func();
284 | });
285 | }
286 | });
287 | };
288 |
289 | /**
290 | * Deletes a user and all relevant information.
291 | * @param {string} username - The username whose details must be deleted
292 | * @param {Function} func - The callback function
293 | */
294 | exports.deleteUser = (username, func) => {
295 | let sql = "SELECT video_id \
296 | FROM videos \
297 | WHERE username = ?";
298 | connection.query(sql, [username], (err, results) => {
299 | if (err) {
300 | func(err);
301 | return;
302 | }
303 |
304 | // First delete all search indices
305 | results.forEach((result) => {
306 | search.deleteDoc("qtube", "video", result.video_id, (err) => {
307 | if(err) {
308 | func(err);
309 | return;
310 | }
311 | });
312 | });
313 |
314 | // Delete search index for the user
315 | search.deleteDoc("qtube", "user", username, (err) => {
316 | if(err) {
317 | func(err);
318 | return;
319 | }
320 |
321 | // Delete the user from the database
322 | sql = "DELETE FROM users WHERE username = ?";
323 | connection.query(sql, [username], (err) => {
324 | if (err) {
325 | func(err);
326 | return;
327 | }
328 |
329 | func();
330 | });
331 | });
332 | });
333 | };
334 |
335 | /**
336 | * Gets all video details
337 | * @param {string} username - The user's username
338 | * @param {number} id - The video_id
339 | * @param {Function} func - The callback function
340 | */
341 | exports.videoDetails = (username, id, func) => {
342 | let sql = "SELECT v.*, u.name AS name, u.dp AS dp, vv.views AS views \
343 | FROM videos v, users u, video_views vv \
344 | WHERE v.username = u.username \
345 | AND vv.video_id = v.video_id \
346 | AND v.video_id = ?";
347 | connection.query(sql, [id], (err, results) => {
348 | if (err) {
349 | func(err);
350 | return;
351 | }
352 |
353 | sql = "SELECT COUNT(*) AS ? \
354 | FROM video_ratings \
355 | WHERE rating = ?";
356 | connection.query(sql, ["upvotes", 1], (e, r) => {
357 | if (e) {
358 | func(e);
359 | return;
360 | }
361 |
362 | connection.query(sql, ["downvotes", -1], (e_, r_) => {
363 | if (e_) {
364 | func(e_);
365 | return;
366 | }
367 |
368 | // And finally, get details of whether the user has voted on this or not.
369 | sql = "SELECT COUNT(*) AS count_, rating AS user_rating \
370 | FROM users AS u \
371 | NATURAL JOIN video_ratings \
372 | WHERE video_id = ? \
373 | AND username = ?";
374 | connection.query(sql, [id, username], (_e, _r) => {
375 | if (_e) {
376 | func(_e);
377 | return;
378 | }
379 |
380 | // Merge the properties of all the results (ES6)
381 | let finalResults = Object.assign({}, results[0], r[0], r_[0], _r[0]);
382 | func(null, finalResults);
383 | });
384 | });
385 | });
386 | });
387 | };
388 |
389 | /**
390 | * Gets the details of all comments and videos on a video.
391 | * @param {number} video_id - The video_id whose comment details are required.
392 | * @param {Function} func - The callback function.
393 | */
394 | exports.comments = (video_id, func) => {
395 | let sql = "SELECT u.name, u.username, u.dp, c.comment_date, c.comment, c.comment_id \
396 | FROM users AS u \
397 | NATURAL JOIN comments AS c \
398 | WHERE video_id = ?";
399 | connection.query(sql, [video_id], (err, results) => {
400 | if (err) {
401 | func(err);
402 | return;
403 | }
404 |
405 | sql = "SELECT u.name, u.username, u.dp, r.reply_date, r.reply_text, r.comment_id \
406 | FROM users AS u \
407 | NATURAL JOIN replies AS r \
408 | \
409 | JOIN comments AS c \
410 | ON r.comment_id = c.comment_id \
411 | WHERE c.video_id = ?";
412 | connection.query(sql, [video_id], (e, r) => {
413 | if (e) {
414 | func(e);
415 | return;
416 | }
417 |
418 | let finalResult = {comments: results, replies: r};
419 | func(null, finalResult);
420 | });
421 | });
422 | };
423 |
424 | /**
425 | * Adds a comment on the given video
426 | * @param {number} video_id - The video_id of the video
427 | * @param {string} comment - The comment to add
428 | * @param {string} username - The user's username
429 | * @param {Function} func - The callback function. Accepts only one argument, the error.
430 | */
431 | exports.addComment = (video_id, comment, username, func) => {
432 | let sql = "INSERT INTO comments (username, video_id, comment, comment_date) \
433 | VALUES (?, ?, ?, ?)";
434 | connection.query(sql, [username, video_id, comment, new Date()], (err) => {
435 | if (err) {
436 | func(err);
437 | return;
438 | }
439 |
440 | func();
441 | });
442 | };
443 |
444 | /**
445 | * Adds a new upvote to the video_ratings table
446 | * @param {number} video_id - The video_id of the video
447 | * @param {string} username - The user who's voting
448 | * @param {number} vote - The vote to add to the table
449 | * @param {Function} func - The callback function. Accepts only one argument.
450 | */
451 | exports.addVote = (video_id, username, vote, func) => {
452 | let sql = "INSERT INTO video_ratings \
453 | VALUES (?, ?, ?)";
454 | connection.query(sql, [username, video_id, vote], (err) => {
455 | if (err) {
456 | func(err);
457 | return;
458 | }
459 |
460 | func();
461 | });
462 | };
463 |
464 | /**
465 | * Swap the upvote with a downvote or vice versa
466 | * @param {number} video_id - The video_id of the video
467 | * @param {string} username - The username of the user
468 | * @param {Function} func - The callback function. Accepts only one argument.
469 | */
470 | exports.swapVote = (video_id, username, func) => {
471 | let sql = "UPDATE video_ratings \
472 | SET rating = IF (rating = 1, -1, 1) \
473 | WHERE username = ? \
474 | AND video_id = ?";
475 | connection.query(sql, [username, video_id], (err) => {
476 | if (err) {
477 | func(err);
478 | return;
479 | }
480 |
481 | func();
482 | });
483 | };
484 |
485 | /**
486 | *
487 | * @param {number} video_id - The video_id of the video
488 | * @param {string} username - The username of the user
489 | * @param {Function} func - The callback function. Accepts only one argument.
490 | */
491 | exports.removeVote = (video_id, username, func) => {
492 | let sql = "DELETE \
493 | FROM video_ratings \
494 | WHERE video_id = ? \
495 | AND username = ?";
496 | connection.query(sql, [video_id, username], (err) => {
497 | if (err) {
498 | func(err);
499 | return;
500 | }
501 |
502 | func();
503 | });
504 | };
505 |
506 | /**
507 | * Toggles the subscription status of a user to another.
508 | * @param {string} username - The user who is being subscribed to (profile)
509 | * @param {string} subscriber - The user who is subscribing
510 | * @param {Function} func - The callback function. Only accepts one argument
511 | */
512 | exports.toggleSubscription = (username, subscriber, func) => {
513 | let sql = "SELECT COUNT(*) AS count \
514 | FROM subscriptions \
515 | WHERE username = ? \
516 | AND subscriber = ?";
517 | connection.query(sql, [username, subscriber], (err, results) => {
518 | if (err) {
519 | func(err);
520 | return;
521 | }
522 |
523 | let count = results[0].count;
524 | if (count == 1) {
525 | // Remove the subscriptions
526 | sql = "DELETE \
527 | FROM subscriptions \
528 | WHERE username = ? \
529 | AND subscriber = ?";
530 | connection.query(sql, [username, subscriber], (e) => {
531 | if (e) {
532 | func(e);
533 | return;
534 | }
535 |
536 | func();
537 | });
538 | } else {
539 | // Subscribe the user
540 | sql = "INSERT INTO subscriptions (username, subscriber) \
541 | VALUES (?, ?)";
542 | connection.query(sql, [username, subscriber], (e) => {
543 | if (e) {
544 | func(e);
545 | return;
546 | }
547 |
548 | func();
549 | });
550 | }
551 | });
552 | };
553 |
554 | /**
555 | * Adds a reply to the specified comment on the given video
556 | * @param {number} comment_id - The comment id which is being replied to
557 | * @param {string} username - The user adding the reply
558 | * @param {string} reply - The reply text
559 | * @param {Function} func - The callback function. Accepts only one argument
560 | */
561 | exports.addReply = (comment_id, username, reply, func) => {
562 | let sql = "INSERT INTO replies (comment_id, username, reply_text, reply_date) \
563 | VALUES (?, ?, ?, ?)";
564 | connection.query(sql, [comment_id, username, reply, new Date()], (err) => {
565 | if (err) {
566 | func(err);
567 | return;
568 | }
569 |
570 | func();
571 | });
572 | };
573 |
574 | /**
575 | * Increments the views for a video
576 | * @param {number} video_id - The id of the video
577 | * @param {Function} func - The callback function. Accepts only one argument
578 | */
579 | exports.incrementViews = (video_id, func) => {
580 | let sql = "CALL increment_views(?)";
581 | connection.query(sql, [video_id], (err) => {
582 | if (err) {
583 | func(err);
584 | return;
585 | }
586 |
587 | func();
588 | });
589 | };
590 |
591 | /**
592 | * Updates the DP of a user.
593 | * @param {string} username - The username of the user
594 | * @param {string} dp - The new DP, in base64
595 | * @param {Function} func - The callback function. Only accepts one argument.
596 | */
597 | exports.updateDp = (username, dp, func) => {
598 | let sql = "UPDATE users \
599 | SET dp = ? \
600 | WHERE username = ?";
601 | connection.query(sql, [dp, username], (err) => {
602 | if (err) {
603 | func(err);
604 | return;
605 | }
606 |
607 | func();
608 | });
609 | };
610 |
611 | /**
612 | * Gets all the videos uploaded by users that the subscriber has subscribed to.
613 | * @param {string} subscriber - The user whose feed must be fetched
614 | * @param {Function} func - The callback function.
615 | */
616 | exports.getSubscribedVideos = (subscriber, func) => {
617 | let sql = "SELECT * \
618 | FROM videos AS v \
619 | NATURAL JOIN video_views AS vv \
620 | WHERE username IN (SELECT username \
621 | FROM subscriptions \
622 | WHERE subscriber = ?) \
623 | AND username <> ? \
624 | ORDER BY vv.views";
625 | connection.query(sql, [subscriber, subscriber], (err, results) => {
626 | if (err) {
627 | func(err);
628 | return;
629 | }
630 |
631 | let groups = groupBy(results, (val) => val.username);
632 | func(null, groups);
633 | });
634 | };
635 |
636 | module.exports = exports;
637 |
--------------------------------------------------------------------------------