├── .dockerignore ├── frontend ├── .gitignore ├── src │ ├── index.html │ ├── config.ts │ ├── actions │ │ ├── themeActions.ts │ │ ├── headerActions.ts │ │ ├── snackbarActions.ts │ │ ├── statsActions.ts │ │ ├── socketActions.ts │ │ ├── authenticationActions.ts │ │ ├── channelActions.ts │ │ └── streamActions.ts │ ├── containers │ │ ├── ThemeContainer.ts │ │ ├── LoginFormContainer.ts │ │ ├── RegisterFormContainer.ts │ │ ├── SnackbarContainer.ts │ │ ├── ChannelContainer.ts │ │ ├── AddChannelContainer.ts │ │ ├── NewStreamContainer.ts │ │ ├── StreamsContainer.ts │ │ ├── HeaderContainer.ts │ │ ├── AuthenticationContainer.ts │ │ └── DashboardContainer.ts │ ├── components │ │ ├── Theme │ │ │ └── Theme.tsx │ │ ├── Streams │ │ │ ├── StreamsLoader.tsx │ │ │ ├── NewStream.tsx │ │ │ └── Streams.tsx │ │ ├── Title │ │ │ └── Title.tsx │ │ ├── Icons │ │ │ ├── Facebook.tsx │ │ │ ├── Twitch.tsx │ │ │ ├── Periscope.tsx │ │ │ └── Youtube.tsx │ │ ├── Player │ │ │ └── Player.tsx │ │ ├── StreamStatusIcon │ │ │ └── StreamStatusIcon.tsx │ │ ├── Socket │ │ │ └── Socket.tsx │ │ ├── App.tsx │ │ ├── LandingPage │ │ │ └── LandingPage.tsx │ │ ├── Authentication │ │ │ └── Authentication.tsx │ │ ├── Snackbar │ │ │ └── Snackbar.tsx │ │ ├── LoginForm │ │ │ └── LoginForm.tsx │ │ ├── RegisterForm │ │ │ └── RegisterForm.tsx │ │ ├── Header │ │ │ └── Header.tsx │ │ ├── AddChannelForm │ │ │ └── AddChannelForm.tsx │ │ └── AddChannel │ │ │ └── AddChannel.tsx │ ├── models.ts │ ├── axios.ts │ ├── reducers │ │ ├── headerReducer.ts │ │ ├── themeReducer.ts │ │ ├── socketReducer.ts │ │ ├── statsReducer.ts │ │ ├── snackbarReducer.ts │ │ ├── channelReducer.ts │ │ ├── authReducer.ts │ │ ├── index.ts │ │ └── streamReducer.ts │ ├── theme.ts │ ├── epics │ │ ├── index.ts │ │ ├── streamEpics.ts │ │ ├── channelEpics.ts │ │ ├── socketEpics.ts │ │ ├── authEpics.ts │ │ └── snackbarEpics.ts │ └── index.tsx ├── .prettierrc ├── tsconfig.json ├── tslint.json ├── webpack.prod.js ├── webpack.config.js └── package.json ├── .gitignore ├── screenshot.png ├── scripts ├── nginx │ ├── Dockerfile │ └── nginx.conf ├── ui │ └── Dockerfile ├── configs.env ├── rtmp │ ├── Dockerfile │ └── .air.toml ├── web │ ├── Dockerfile │ └── .air.toml └── wait-for-it.sh ├── helm └── go-rtmp-web-server │ ├── Chart.yaml │ ├── templates │ ├── namespace.yaml │ ├── mysql-cluster-ip.yaml │ ├── redis-cluster-ip.yaml │ ├── mysql-persistent-volume-claim.yaml │ ├── frontend-cluster-ip.yaml │ ├── preview-persistent-volume-claim.yaml │ ├── api-cluster-ip.yaml │ ├── rtmp-cluster-ip.yaml │ ├── redis-deployment.yaml │ ├── frontend-deployment.yaml │ ├── rtmp-deployment.yaml │ ├── mysql-deployment.yaml │ ├── api-deployment.yaml │ └── ingress-service.yaml │ ├── values.yaml │ └── .helmignore ├── k8s ├── mysql-cluster-ip.yaml ├── redis-cluster-ip.yaml ├── mysql-persistent-volume-claim.yaml ├── frontend-cluster-ip.yaml ├── preview-persistent-volume-claim.yaml ├── api-cluster-ip.yaml ├── rtmp-cluster-ip.yaml ├── redis-deployment.yaml ├── frontend-deployment.yaml ├── rtmp-deployment.yaml ├── mysql-deployment.yaml ├── api-deployment.yaml └── ingress-service.yaml ├── models ├── stream_model.go ├── channel_model.go └── user_model.go ├── router ├── websocket_router.go ├── stream_router.go ├── user_router.go ├── channel_router.go └── router.go ├── docker ├── multi-streamer-ui │ ├── Dockerfile │ └── nginx.conf ├── multi-streamer-api │ └── Dockerfile └── multi-streamer-rtmp │ └── Dockerfile ├── amf ├── encoder.go └── decoder.go ├── go.mod ├── README.md ├── controllers ├── redis.go ├── rpc.go ├── stream.go ├── channels.go ├── users.go ├── websocket.go ├── youtube.go ├── twitch.go └── app_context.go ├── rtmp ├── server.go ├── handshake.go ├── connection.go ├── preview.go └── chunk.go ├── docker-compose.yml └── main.go /.dockerignore: -------------------------------------------------------------------------------- 1 | **/node_modules -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /dist/ -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/praveen001/go-rtmp-web-server/HEAD/screenshot.png -------------------------------------------------------------------------------- /scripts/nginx/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx:stable 2 | 3 | COPY ./scripts/nginx/nginx.conf /etc/nginx/nginx.conf 4 | -------------------------------------------------------------------------------- /helm/go-rtmp-web-server/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: RTMP Restreaming Server in Go 3 | version: 0.0.1 4 | appVersion: 0.0.1 -------------------------------------------------------------------------------- /frontend/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Test 4 | 5 | 6 |
7 | 8 | -------------------------------------------------------------------------------- /helm/go-rtmp-web-server/templates/namespace.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Namespace 3 | metadata: 4 | name: go-rtmp-web-server 5 | labels: 6 | namepace: go-rtmp-web-server -------------------------------------------------------------------------------- /frontend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "trailingComma": "none", 5 | "tabWidth": 2, 6 | "useTabs": false, 7 | "bracketSpacing": true 8 | } 9 | -------------------------------------------------------------------------------- /frontend/src/config.ts: -------------------------------------------------------------------------------- 1 | // export const BaseUrl = 'http://localhost:5000'; 2 | // export const SocketUrl = 'ws://localhost:5000/ws/connect?token='; 3 | 4 | export const BaseUrl = `/v1/api`; 5 | export const SocketUrl = `ws://${window.location.hostname}`; 6 | -------------------------------------------------------------------------------- /scripts/ui/Dockerfile: -------------------------------------------------------------------------------- 1 | ############ 2 | # FRONTEND # 3 | ############ 4 | FROM node AS build-ui 5 | 6 | WORKDIR /ui 7 | 8 | COPY ./frontend/package.json . 9 | COPY ./frontend/yarn.lock . 10 | 11 | RUN yarn 12 | 13 | COPY ./frontend . 14 | 15 | -------------------------------------------------------------------------------- /k8s/mysql-cluster-ip.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: mysql-cluster-ip 5 | labels: 6 | service: mysql-cluster-ip 7 | 8 | spec: 9 | type: ClusterIP 10 | ports: 11 | - port: 3306 12 | selector: 13 | app: mysql -------------------------------------------------------------------------------- /k8s/redis-cluster-ip.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: redis-cluster-ip 5 | labels: 6 | service: redis-cluster-ip 7 | 8 | spec: 9 | type: ClusterIP 10 | ports: 11 | - port: 6379 12 | selector: 13 | app: redis -------------------------------------------------------------------------------- /k8s/mysql-persistent-volume-claim.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: PersistentVolumeClaim 3 | 4 | metadata: 5 | name: mysql-persistent-volume-claim 6 | 7 | spec: 8 | accessModes: 9 | - ReadWriteOnce 10 | resources: 11 | requests: 12 | storage: 1Gi -------------------------------------------------------------------------------- /k8s/frontend-cluster-ip.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: frontend-cluster-ip 5 | labels: 6 | service: frontend-cluster-ip 7 | 8 | spec: 9 | type: ClusterIP 10 | ports: 11 | - port: 80 12 | selector: 13 | app: frontend -------------------------------------------------------------------------------- /k8s/preview-persistent-volume-claim.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: PersistentVolumeClaim 3 | 4 | metadata: 5 | name: preview-persistent-volume-claim 6 | 7 | spec: 8 | accessModes: 9 | - ReadWriteMany 10 | resources: 11 | requests: 12 | storage: 2Gi -------------------------------------------------------------------------------- /helm/go-rtmp-web-server/templates/mysql-cluster-ip.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: mysql-cluster-ip 5 | labels: 6 | service: mysql-cluster-ip 7 | 8 | spec: 9 | type: ClusterIP 10 | ports: 11 | - port: 3306 12 | selector: 13 | app: mysql -------------------------------------------------------------------------------- /helm/go-rtmp-web-server/templates/redis-cluster-ip.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: redis-cluster-ip 5 | labels: 6 | service: redis-cluster-ip 7 | 8 | spec: 9 | type: ClusterIP 10 | ports: 11 | - port: 6379 12 | selector: 13 | app: redis -------------------------------------------------------------------------------- /helm/go-rtmp-web-server/templates/mysql-persistent-volume-claim.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: PersistentVolumeClaim 3 | 4 | metadata: 5 | name: mysql-persistent-volume-claim 6 | 7 | spec: 8 | accessModes: 9 | - ReadWriteOnce 10 | resources: 11 | requests: 12 | storage: 1Gi -------------------------------------------------------------------------------- /models/stream_model.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | // Stream .. 4 | type Stream struct { 5 | Model 6 | Name string `json:"name"` 7 | Key string `json:"key"` 8 | UserID int `json:"userId"` 9 | Channels []Channel `json:"channels" gorm:"foreignkey:StreamID"` 10 | } 11 | -------------------------------------------------------------------------------- /helm/go-rtmp-web-server/templates/frontend-cluster-ip.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: frontend-cluster-ip 5 | labels: 6 | service: frontend-cluster-ip 7 | 8 | spec: 9 | type: ClusterIP 10 | ports: 11 | - port: 80 12 | selector: 13 | app: frontend -------------------------------------------------------------------------------- /helm/go-rtmp-web-server/templates/preview-persistent-volume-claim.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: PersistentVolumeClaim 3 | 4 | metadata: 5 | name: preview-persistent-volume-claim 6 | 7 | spec: 8 | accessModes: 9 | - ReadWriteMany 10 | resources: 11 | requests: 12 | storage: 2Gi -------------------------------------------------------------------------------- /router/websocket_router.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "github.com/go-chi/chi" 5 | ) 6 | 7 | // websocketRouter Add channel route handlers 8 | func (cr *CustomRouter) websocketRouter() *chi.Mux { 9 | router := chi.NewRouter() 10 | 11 | router.Get("/connect", cr.HandleWebsocket) 12 | 13 | return router 14 | } 15 | -------------------------------------------------------------------------------- /scripts/configs.env: -------------------------------------------------------------------------------- 1 | UI_ENDPOINT=http://localhost 2 | API_ENDPOINT=http://localhost 3 | 4 | YOUTUBE_CLIENT_ID=672041665322-5papoq1ose8s8aqb8j4c5abjgb9a5d0u.apps.googleusercontent.com 5 | YOUTUBE_CLIENT_SECRET=kmjcipIVYcgqpzUK3kZPsguw 6 | 7 | TWITCH_CLIENT_ID=d72o1jp8j07z6z3km9zxaz266us9lx 8 | TWITCH_CLIENT_SECRET=mc6tzwdmpbapslalifmzycgpkn011w -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "commonjs", 5 | "sourceMap": false, 6 | "outDir": "./dist", 7 | "jsx": "react", 8 | "esModuleInterop": true, 9 | "allowSyntheticDefaultImports": true, 10 | "lib": ["es2015", "dom"], 11 | "resolveJsonModule": true 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /k8s/api-cluster-ip.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: api-cluster-ip 5 | labels: 6 | service: api-cluster-ip 7 | 8 | spec: 9 | type: ClusterIP 10 | ports: 11 | - name: http 12 | port: 5000 13 | targetPort: 5000 14 | - name: grpc 15 | port: 4005 16 | targetPort: 4005 17 | selector: 18 | app: api -------------------------------------------------------------------------------- /k8s/rtmp-cluster-ip.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: rtmp-cluster-ip 5 | labels: 6 | service: rtmp-cluster-ip 7 | 8 | spec: 9 | type: ClusterIP 10 | ports: 11 | - name: rtmp 12 | port: 1935 13 | targetPort: 1935 14 | - name: preview-nginx-server 15 | port: 80 16 | targetPort: 80 17 | selector: 18 | app: rtmp -------------------------------------------------------------------------------- /helm/go-rtmp-web-server/templates/api-cluster-ip.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: api-cluster-ip 5 | labels: 6 | service: api-cluster-ip 7 | 8 | spec: 9 | type: ClusterIP 10 | ports: 11 | - name: http 12 | port: 5000 13 | targetPort: 5000 14 | - name: grpc 15 | port: 4005 16 | targetPort: 4005 17 | selector: 18 | app: api -------------------------------------------------------------------------------- /helm/go-rtmp-web-server/templates/rtmp-cluster-ip.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: rtmp-cluster-ip 5 | labels: 6 | service: rtmp-cluster-ip 7 | 8 | spec: 9 | type: ClusterIP 10 | ports: 11 | - name: rtmp 12 | port: 1935 13 | targetPort: 1935 14 | - name: preview-nginx-server 15 | port: 80 16 | targetPort: 80 17 | selector: 18 | app: rtmp -------------------------------------------------------------------------------- /frontend/src/actions/themeActions.ts: -------------------------------------------------------------------------------- 1 | export enum ThemeActionTypes { 2 | TOGGLE_THEME = 'theme/toggleTheme' 3 | } 4 | 5 | export function toggleTheme(): IToggleThemeAction { 6 | return { 7 | type: ThemeActionTypes.TOGGLE_THEME 8 | }; 9 | } 10 | 11 | export interface IToggleThemeAction { 12 | type: ThemeActionTypes.TOGGLE_THEME; 13 | } 14 | 15 | export type CounterAction = IToggleThemeAction; 16 | -------------------------------------------------------------------------------- /docker/multi-streamer-ui/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node AS ui-builder 2 | 3 | WORKDIR /frontend 4 | 5 | COPY ./frontend/package.json . 6 | COPY ./frontend/yarn.lock . 7 | 8 | RUN yarn 9 | 10 | COPY ./frontend . 11 | 12 | RUN yarn build 13 | 14 | FROM nginx:stable 15 | 16 | COPY ./docker/multi-streamer-ui/nginx.conf /etc/nginx/nginx.conf 17 | 18 | COPY --from=ui-builder /frontend/dist /usr/share/nginx/html 19 | 20 | EXPOSE 80 -------------------------------------------------------------------------------- /router/stream_router.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import "github.com/go-chi/chi" 4 | 5 | // streamRouter Add channel route handlers 6 | func (cr *CustomRouter) streamRouter() *chi.Mux { 7 | router := chi.NewRouter() 8 | 9 | router.Post("/add", cr.Authentication(cr.AddStream)) 10 | router.Get("/list", cr.Authentication(cr.ListStreams)) 11 | router.Delete("/delete", cr.Authentication(cr.DeleteStream)) 12 | return router 13 | } 14 | -------------------------------------------------------------------------------- /models/channel_model.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "github.com/jinzhu/gorm" 5 | ) 6 | 7 | // Channel .. 8 | type Channel struct { 9 | Model 10 | Name string `json:"name"` 11 | URL string `json:"url"` 12 | Key string `json:"key"` 13 | Enabled bool `json:"enabled"` 14 | StreamID int `json:"streamId"` 15 | } 16 | 17 | // AddChannel to database 18 | func (c Channel) AddChannel(db *gorm.DB) { 19 | 20 | } 21 | -------------------------------------------------------------------------------- /scripts/rtmp/Dockerfile: -------------------------------------------------------------------------------- 1 | ########### 2 | # BACKEND # 3 | ########### 4 | FROM golang:1.14 AS api-builder 5 | 6 | WORKDIR /go/src/github.com/praveen001/go-rtmp-web-server 7 | 8 | COPY ./go.sum . 9 | COPY ./go.mod . 10 | 11 | RUN chmod +x -R . 12 | 13 | RUN go mod download 14 | RUN curl -sSfL https://raw.githubusercontent.com/cosmtrek/air/master/install.sh | sh -s -- -b $(go env GOPATH)/bin 15 | 16 | COPY . . 17 | 18 | RUN chmod +x -R . 19 | -------------------------------------------------------------------------------- /scripts/web/Dockerfile: -------------------------------------------------------------------------------- 1 | ########### 2 | # BACKEND # 3 | ########### 4 | FROM golang:1.14 AS api-builder 5 | 6 | WORKDIR /go/src/github.com/praveen001/go-rtmp-web-server 7 | 8 | COPY ./go.sum . 9 | COPY ./go.mod . 10 | 11 | RUN chmod +x -R . 12 | 13 | RUN go mod download 14 | RUN curl -sSfL https://raw.githubusercontent.com/cosmtrek/air/master/install.sh | sh -s -- -b $(go env GOPATH)/bin 15 | 16 | COPY . . 17 | 18 | RUN chmod +x -R . 19 | -------------------------------------------------------------------------------- /k8s/redis-deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | 4 | metadata: 5 | name: redis-deployment 6 | labels: 7 | app: redis 8 | 9 | spec: 10 | selector: 11 | matchLabels: 12 | app: redis 13 | 14 | template: 15 | metadata: 16 | labels: 17 | app: redis 18 | 19 | spec: 20 | containers: 21 | - name: redis 22 | image: redis 23 | ports: 24 | - containerPort: 6379 -------------------------------------------------------------------------------- /helm/go-rtmp-web-server/values.yaml: -------------------------------------------------------------------------------- 1 | endpoint: 2 | api: http://localhost 3 | ui: http://localhost 4 | 5 | mysql: 6 | user: multi-streamer 7 | password: multi-streamer 8 | database: multi-streamer 9 | 10 | youtube: 11 | client_id: 813004830141-3tamleghb88jb8c38pjdfbobp2mg7qt3.apps.googleusercontent.com 12 | client_secret: '' 13 | 14 | twitch: 15 | client_id: d72o1jp8j07z6z3km9zxaz266us9lx 16 | client_secret: mc6tzwdmpbapslalifmzycgpkn011w -------------------------------------------------------------------------------- /router/user_router.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "github.com/go-chi/chi" 5 | ) 6 | 7 | // userRouter Add user route handlers 8 | func (cr *CustomRouter) userRouter() *chi.Mux { 9 | router := chi.NewRouter() 10 | 11 | router.Post("/register", cr.RegisterUser) 12 | router.Get("/verify", cr.VerifyUser) 13 | router.Post("/login", cr.LoginUser) 14 | router.Get("/tokeninfo", cr.Authentication(cr.TokenInfo)) 15 | 16 | return router 17 | } 18 | -------------------------------------------------------------------------------- /frontend/src/containers/ThemeContainer.ts: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import Theme, { IThemeStateProps } from '../components/Theme/Theme'; 3 | import { IState } from '../reducers'; 4 | 5 | function mapStateToProps(state: IState): IThemeStateProps { 6 | return { 7 | type: state.theme.type 8 | }; 9 | } 10 | 11 | const mapDispatchToProps = {}; 12 | 13 | export default connect( 14 | mapStateToProps, 15 | mapDispatchToProps 16 | )(Theme); 17 | -------------------------------------------------------------------------------- /docker/multi-streamer-api/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.14 AS api-builder 2 | 3 | WORKDIR /go/src/github.com/praveen001/go-rtmp-web-server 4 | 5 | COPY ./go.sum . 6 | COPY ./go.mod . 7 | 8 | RUN go mod download 9 | 10 | COPY . . 11 | 12 | RUN CGO_ENABLED=0 go build 13 | 14 | FROM alpine 15 | 16 | WORKDIR /app 17 | 18 | COPY --from=api-builder /go/src/github.com/praveen001/go-rtmp-web-server/go-rtmp-web-server ./go-rtmp-web-server 19 | 20 | CMD ["./go-rtmp-web-server", "web"] -------------------------------------------------------------------------------- /amf/encoder.go: -------------------------------------------------------------------------------- 1 | package amf 2 | 3 | import ( 4 | "github.com/praveen001/joy4/format/flv/flvio" 5 | ) 6 | 7 | // Encode the payload into AMF binary form 8 | func Encode(args ...interface{}) ([]byte, int) { 9 | var len int 10 | for _, val := range args { 11 | len += flvio.LenAMF0Val(val) 12 | } 13 | res := make([]byte, len) 14 | var offset int 15 | for _, val := range args { 16 | offset += flvio.FillAMF0Val(res[offset:], val) 17 | } 18 | 19 | return res, len 20 | } 21 | -------------------------------------------------------------------------------- /helm/go-rtmp-web-server/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *.orig 18 | *~ 19 | # Various IDEs 20 | .project 21 | .idea/ 22 | *.tmproj 23 | .vscode/ 24 | -------------------------------------------------------------------------------- /helm/go-rtmp-web-server/templates/redis-deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | 4 | metadata: 5 | name: redis-deployment 6 | labels: 7 | deployment: redis 8 | 9 | spec: 10 | selector: 11 | matchLabels: 12 | app: redis 13 | 14 | template: 15 | metadata: 16 | labels: 17 | app: redis 18 | 19 | spec: 20 | containers: 21 | - name: redis 22 | image: redis 23 | ports: 24 | - containerPort: 6379 -------------------------------------------------------------------------------- /frontend/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": [ 4 | "tslint:recommended", 5 | "tslint-config-standard", 6 | "tslint-react", 7 | "tslint-config-prettier" 8 | ], 9 | "jsRules": {}, 10 | "rules": { 11 | "ordered-imports": true, 12 | "object-literal-sort-keys": false, 13 | "member-ordering": false, 14 | "jsx-no-lambda": false, 15 | "jsx-boolean-value": false, 16 | "member-access": false, 17 | "max-line-length": [true, 150] 18 | }, 19 | "rulesDirectory": [] 20 | } 21 | -------------------------------------------------------------------------------- /docker/multi-streamer-rtmp/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.14 AS api-builder 2 | 3 | WORKDIR /go/src/github.com/praveen001/go-rtmp-web-server 4 | 5 | COPY ./go.mod . 6 | COPY ./go.sum . 7 | 8 | RUN go mod download 9 | 10 | COPY . . 11 | 12 | RUN CGO_ENABLED=0 go build 13 | 14 | FROM ubuntu:18.04 15 | 16 | WORKDIR /app 17 | 18 | RUN apt-get update 19 | 20 | RUN apt-get --yes install ffmpeg 21 | 22 | COPY --from=api-builder /go/src/github.com/praveen001/go-rtmp-web-server/go-rtmp-web-server ./go-rtmp-web-server 23 | 24 | CMD ["./go-rtmp-web-server", "rtmp"] -------------------------------------------------------------------------------- /frontend/src/containers/LoginFormContainer.ts: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | 3 | // Types 4 | import LoginForm, { IDispatchProps } from '../components/LoginForm/LoginForm'; 5 | import { IState } from '../reducers'; 6 | 7 | // Actions 8 | import { login } from '../actions/authenticationActions'; 9 | 10 | function mapStateToProps(state: IState) { 11 | return {}; 12 | } 13 | 14 | const mapDispatchToProps: IDispatchProps = { 15 | login 16 | }; 17 | 18 | export default connect<{}, IDispatchProps>( 19 | mapStateToProps, 20 | mapDispatchToProps 21 | )(LoginForm); 22 | -------------------------------------------------------------------------------- /frontend/src/containers/RegisterFormContainer.ts: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | 3 | // Types 4 | import RegisterForm, { 5 | IDispatchProps 6 | } from '../components/RegisterForm/RegisterForm'; 7 | import { IState } from '../reducers'; 8 | 9 | // Actions 10 | import { register } from '../actions/authenticationActions'; 11 | 12 | function mapStateToProps(state: IState) { 13 | return {}; 14 | } 15 | 16 | const mapDispatchToProps: IDispatchProps = { 17 | register 18 | }; 19 | 20 | export default connect<{}, IDispatchProps>( 21 | mapStateToProps, 22 | mapDispatchToProps 23 | )(RegisterForm); 24 | -------------------------------------------------------------------------------- /frontend/src/components/Theme/Theme.tsx: -------------------------------------------------------------------------------- 1 | import { MuiThemeProvider, Theme } from '@material-ui/core/styles'; 2 | import React from 'react'; 3 | 4 | import themeCreator from '../../theme'; 5 | 6 | export default function Theme(props: IThemeProps) { 7 | const theme: Theme = themeCreator(props.type); 8 | 9 | return {props.children}; 10 | } 11 | 12 | export interface IThemeOwnProps { 13 | children: React.ReactNode; 14 | } 15 | 16 | export interface IThemeStateProps { 17 | type: string; 18 | } 19 | 20 | export type IThemeProps = IThemeOwnProps & IThemeStateProps; 21 | -------------------------------------------------------------------------------- /frontend/src/actions/headerActions.ts: -------------------------------------------------------------------------------- 1 | export enum HeaderActionTypes { 2 | OPEN_MENU = 'header/openMenu', 3 | CLOSE_MENU = 'header/closeMenu' 4 | } 5 | 6 | export function openMenu() { 7 | return { 8 | type: HeaderActionTypes.OPEN_MENU 9 | }; 10 | } 11 | 12 | export function closeMenu() { 13 | return { 14 | type: HeaderActionTypes.CLOSE_MENU 15 | }; 16 | } 17 | 18 | export interface IOpenMenuAction { 19 | type: HeaderActionTypes.OPEN_MENU; 20 | } 21 | 22 | export interface ICloseMenuAction { 23 | type: HeaderActionTypes.CLOSE_MENU; 24 | } 25 | 26 | export type HeaderAction = IOpenMenuAction & ICloseMenuAction; 27 | -------------------------------------------------------------------------------- /frontend/src/models.ts: -------------------------------------------------------------------------------- 1 | export enum ApiStatus { 2 | IN_PROGRESS = 'IN_PROGRESS', 3 | SUCCESS = 'SUCCESS', 4 | FAILURE = 'FAILURE' 5 | } 6 | 7 | export interface IStream { 8 | id: number; 9 | name: string; 10 | key: string; 11 | userId: number; 12 | deleting: ApiStatus; 13 | url: string; 14 | } 15 | export interface IChannel { 16 | id: number; 17 | name: string; 18 | url: string; 19 | key: string; 20 | enabled: boolean; 21 | } 22 | 23 | export interface IStats { 24 | online: boolean; 25 | previewReady: boolean; 26 | } 27 | 28 | export const defaultStats: IStats = { 29 | online: false, 30 | previewReady: false 31 | }; 32 | -------------------------------------------------------------------------------- /frontend/src/containers/SnackbarContainer.ts: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { hideSnackbar } from '../actions/snackbarActions'; 3 | import Snackbar, { 4 | IToastDispatchProps, 5 | IToastStateProps 6 | } from '../components/Snackbar/Snackbar'; 7 | import { IState } from '../reducers'; 8 | 9 | function mapStateToProps(state: IState): IToastStateProps { 10 | return { 11 | toasts: state.snackbar.ids.map(id => state.snackbar.byId[id]) 12 | }; 13 | } 14 | 15 | const mapDispatchToProps: IToastDispatchProps = { 16 | hideSnackbar 17 | }; 18 | 19 | export default connect( 20 | mapStateToProps, 21 | mapDispatchToProps 22 | )(Snackbar); 23 | -------------------------------------------------------------------------------- /frontend/src/axios.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { BaseUrl } from './config'; 3 | import { store } from './index'; 4 | 5 | const axiosInstance = axios.create({ 6 | baseURL: BaseUrl 7 | }); 8 | 9 | axiosInstance.interceptors.request.use( 10 | config => { 11 | const state = store.getState(); 12 | 13 | if (state.auth.token) { 14 | config.headers.Authorization = state.auth.token; 15 | } 16 | 17 | return config; 18 | }, 19 | error => Promise.reject(error) 20 | ); 21 | 22 | axiosInstance.interceptors.response.use( 23 | response => response, 24 | error => Promise.reject(error) 25 | ); 26 | 27 | export default axiosInstance; 28 | -------------------------------------------------------------------------------- /frontend/src/reducers/headerReducer.ts: -------------------------------------------------------------------------------- 1 | import produce from 'immer'; 2 | import { HeaderActionTypes } from '../actions/headerActions'; 3 | 4 | export const initialHeaderState: IHeaderState = { 5 | open: false 6 | }; 7 | 8 | export default function headerReducer(state = initialHeaderState, action) { 9 | return produce(state, draft => { 10 | switch (action.type) { 11 | case HeaderActionTypes.OPEN_MENU: 12 | draft.open = true; 13 | break; 14 | 15 | case HeaderActionTypes.CLOSE_MENU: 16 | draft.open = false; 17 | break; 18 | } 19 | }); 20 | } 21 | 22 | export interface IHeaderState { 23 | open: boolean; 24 | } 25 | -------------------------------------------------------------------------------- /frontend/src/containers/ChannelContainer.ts: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | 3 | // Types 4 | import AddChannelForm, { 5 | IDispatchProps, 6 | IOwnProps 7 | } from '../components/AddChannelForm/AddChannelForm'; 8 | import { IState } from '../reducers'; 9 | 10 | // Actions 11 | import { addChannel } from '../actions/channelActions'; 12 | 13 | function mapStateToProps(state: IState) { 14 | return { 15 | streamId: state.streams.streamId 16 | }; 17 | } 18 | 19 | const mapDispatchToProps: IDispatchProps = { 20 | addChannel 21 | }; 22 | 23 | export default connect( 24 | mapStateToProps, 25 | mapDispatchToProps 26 | )(AddChannelForm); 27 | -------------------------------------------------------------------------------- /frontend/src/containers/AddChannelContainer.ts: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { hideAddChannel } from '../actions/channelActions'; 3 | import AddChannel, { 4 | IAddChannelDispatchProps, 5 | IAddChannelStatusProps 6 | } from '../components/AddChannel/AddChannel'; 7 | import { IState } from '../reducers'; 8 | 9 | function mapStateToProps(state: IState): IAddChannelStatusProps { 10 | return { 11 | open: state.channels.addChannelOpen, 12 | streamId: state.streams.streamId 13 | }; 14 | } 15 | 16 | const mapDispatchToProps: IAddChannelDispatchProps = { 17 | hideAddChannel 18 | }; 19 | 20 | export default connect( 21 | mapStateToProps, 22 | mapDispatchToProps 23 | )(AddChannel); 24 | -------------------------------------------------------------------------------- /frontend/src/reducers/themeReducer.ts: -------------------------------------------------------------------------------- 1 | import produce from 'immer'; 2 | 3 | import { CounterAction, ThemeActionTypes } from '../actions/themeActions'; 4 | 5 | export const initialThemeState: IThemeState = { 6 | type: 'dark' 7 | }; 8 | 9 | export default function themeReducer( 10 | state: IThemeState = initialThemeState, 11 | action: CounterAction 12 | ): IThemeState { 13 | return produce(state, draft => { 14 | switch (action.type) { 15 | case ThemeActionTypes.TOGGLE_THEME: 16 | draft.type = draft.type === 'light' ? 'dark' : 'light'; 17 | break; 18 | 19 | default: 20 | break; 21 | } 22 | }); 23 | } 24 | 25 | export interface IThemeState { 26 | type: string; 27 | } 28 | -------------------------------------------------------------------------------- /router/channel_router.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "github.com/go-chi/chi" 5 | ) 6 | 7 | // channelRouter Add channel route handlers 8 | func (cr *CustomRouter) channelRouter() *chi.Mux { 9 | router := chi.NewRouter() 10 | 11 | router.Post("/add", cr.Authentication(cr.AddChannel)) 12 | router.Get("/list", cr.Authentication(cr.ListChannels)) 13 | router.Delete("/delete", cr.Authentication(cr.DeleteChannel)) 14 | 15 | router.Get("/youtube", cr.Authentication(cr.GoogleAuth)) 16 | router.Get("/youtube/callback", cr.Authentication(cr.GoogleAuthCallback)) 17 | 18 | router.Get("/twitch", cr.Authentication(cr.TwitchAuth)) 19 | router.Get("/twitch/callback", cr.Authentication(cr.TwitchAuthCallback)) 20 | 21 | return router 22 | } 23 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/praveen001/go-rtmp-web-server 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/dgrijalva/jwt-go v3.2.0+incompatible 7 | github.com/go-chi/chi v4.1.2+incompatible 8 | github.com/gomodule/redigo v1.8.2 9 | github.com/google/uuid v1.1.2 10 | github.com/gorilla/websocket v1.4.2 11 | github.com/jinzhu/gorm v1.9.16 12 | github.com/nareix/joy4 v0.0.0-20200507095837-05a4ffbb5369 // indirect 13 | github.com/praveen001/go-rtmp-grpc v0.0.0-20181220144517-7a98ba4ed346 14 | github.com/praveen001/joy4 v0.0.0-20181220185709-7181f3f7230f 15 | github.com/rs/cors v1.7.0 16 | golang.org/x/net v0.0.0-20200927032502-5d4f70055728 17 | golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43 18 | google.golang.org/api v0.32.0 19 | google.golang.org/grpc v1.32.0 20 | ) 21 | -------------------------------------------------------------------------------- /frontend/src/containers/NewStreamContainer.ts: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | 3 | import { closeNewStreamDialog, createStream } from '../actions/streamActions'; 4 | import NewStream, { 5 | INewStreamDispatchProps, 6 | INewStreamStateProps 7 | } from '../components/Streams/NewStream'; 8 | import { IState } from '../reducers'; 9 | 10 | function mapStateToProps(state: IState): INewStreamStateProps { 11 | return { 12 | open: state.streams.open, 13 | loadingStatus: state.streams.addingStatus 14 | }; 15 | } 16 | 17 | const mapDispatchToProps: INewStreamDispatchProps = { 18 | closeNewStreamDialog, 19 | createStream 20 | }; 21 | 22 | export default connect( 23 | mapStateToProps, 24 | mapDispatchToProps 25 | )(NewStream); 26 | -------------------------------------------------------------------------------- /k8s/frontend-deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | 4 | metadata: 5 | name: frontend-deployment 6 | labels: 7 | app: frontend 8 | 9 | spec: 10 | selector: 11 | matchLabels: 12 | app: frontend 13 | 14 | template: 15 | metadata: 16 | labels: 17 | app: frontend 18 | 19 | spec: 20 | volumes: 21 | - name: preview-storage 22 | persistentVolumeClaim: 23 | claimName: preview-persistent-volume-claim 24 | containers: 25 | - name: multi-streamer-ui 26 | image: praveenraj9495/multi-streamer-ui:v0.2.0 27 | ports: 28 | - containerPort: 80 29 | volumeMounts: 30 | - name: preview-storage 31 | mountPath: /usr/share/nginx/html/hls-preview 32 | subPath: hls-preview -------------------------------------------------------------------------------- /frontend/src/components/Streams/StreamsLoader.tsx: -------------------------------------------------------------------------------- 1 | import CircularProgress from '@material-ui/core/CircularProgress'; 2 | import React, { useEffect } from 'react'; 3 | import { useDispatch, useSelector } from 'react-redux'; 4 | import { loadStreams } from '../../actions/streamActions'; 5 | import { ApiStatus } from '../../models'; 6 | import { IState } from '../../reducers'; 7 | 8 | const StreamsLoader: React.FC = props => { 9 | const dispatch = useDispatch(); 10 | const loadingStatus = useSelector( 11 | (state: IState) => state.streams.loadingStatus 12 | ); 13 | 14 | useEffect(() => { 15 | dispatch(loadStreams()); 16 | }, []); 17 | 18 | if (loadingStatus === ApiStatus.IN_PROGRESS) { 19 | return ; 20 | } 21 | return <>{props.children}; 22 | }; 23 | 24 | export default StreamsLoader; 25 | -------------------------------------------------------------------------------- /frontend/src/containers/StreamsContainer.ts: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | 3 | import { 4 | deleteStream, 5 | loadStreams, 6 | openNewStreamDialog 7 | } from '../actions/streamActions'; 8 | import Streams, { 9 | IStreamsDispatchProps, 10 | IStreamsStateProps 11 | } from '../components/Streams/Streams'; 12 | import { IState } from '../reducers'; 13 | 14 | function mapStateToProps(state: IState): IStreamsStateProps { 15 | return { 16 | streams: state.streams.ids.map(id => state.streams.byId[id]), 17 | statsById: state.stats.byId 18 | }; 19 | } 20 | 21 | const mapDispatchToProps: IStreamsDispatchProps = { 22 | openNewStreamDialog, 23 | deleteStream 24 | }; 25 | 26 | export default connect( 27 | mapStateToProps, 28 | mapDispatchToProps 29 | )(Streams); 30 | -------------------------------------------------------------------------------- /frontend/src/components/Title/Title.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createStyles, 3 | Theme, 4 | withStyles, 5 | WithStyles 6 | } from '@material-ui/core/styles'; 7 | import React from 'react'; 8 | 9 | const styles = (theme: Theme) => 10 | createStyles({ 11 | title: { 12 | display: 'flex', 13 | justifyContent: 'space-between', 14 | alignItems: 'center', 15 | borderBottom: `1px solid ${theme.palette.divider}`, 16 | paddingBottom: theme.spacing.unit, 17 | marginBottom: theme.spacing.unit, 18 | height: 40 19 | } 20 | }); 21 | 22 | class Title extends React.Component> { 23 | render() { 24 | const { classes } = this.props; 25 | return
{this.props.children}
; 26 | } 27 | } 28 | 29 | export default withStyles(styles)(Title); 30 | -------------------------------------------------------------------------------- /helm/go-rtmp-web-server/templates/frontend-deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | 4 | metadata: 5 | name: frontend-deployment 6 | labels: 7 | deployment: frontend 8 | 9 | spec: 10 | selector: 11 | matchLabels: 12 | app: frontend 13 | 14 | template: 15 | metadata: 16 | labels: 17 | app: frontend 18 | 19 | spec: 20 | volumes: 21 | - name: preview-storage 22 | persistentVolumeClaim: 23 | claimName: preview-persistent-volume-claim 24 | containers: 25 | - name: multi-streamer-ui 26 | image: praveenraj9495/multi-streamer-ui:v0.2.0 27 | ports: 28 | - containerPort: 80 29 | volumeMounts: 30 | - name: preview-storage 31 | mountPath: /usr/share/nginx/html/hls-preview 32 | subPath: hls-preview -------------------------------------------------------------------------------- /frontend/src/containers/HeaderContainer.ts: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { logout } from '../actions/authenticationActions'; 3 | import { closeMenu, openMenu } from '../actions/headerActions'; 4 | import { toggleTheme } from '../actions/themeActions'; 5 | import Header, { 6 | IHeaderDispatchProps, 7 | IHeaderStateProps 8 | } from '../components/Header/Header'; 9 | import { IState } from '../reducers'; 10 | 11 | function mapStateToProps(state: IState): IHeaderStateProps { 12 | return { 13 | open: state.header.open, 14 | user: state.auth.user, 15 | theme: state.theme.type 16 | }; 17 | } 18 | 19 | const mapDispatchToProps: IHeaderDispatchProps = { 20 | openMenu, 21 | closeMenu, 22 | toggleTheme, 23 | logout 24 | }; 25 | 26 | export default connect( 27 | mapStateToProps, 28 | mapDispatchToProps 29 | )(Header); 30 | -------------------------------------------------------------------------------- /frontend/src/containers/AuthenticationContainer.ts: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | 3 | // Types 4 | import Authentication, { 5 | IDispatchProps, 6 | IStateProps 7 | } from '../components/Authentication/Authentication'; 8 | import { IState } from '../reducers'; 9 | 10 | // Actions 11 | import { withRouter } from 'react-router'; 12 | import { authenticateUser } from '../actions/authenticationActions'; 13 | 14 | function mapStateToProps(state: IState): IStateProps { 15 | return { 16 | isAuthenticated: state.auth.isAuthenticated, 17 | token: state.auth.token, 18 | loading: state.auth.loading, 19 | loaded: state.auth.loaded 20 | }; 21 | } 22 | 23 | const mapDispatchToProps: IDispatchProps = { 24 | authenticateUser 25 | }; 26 | 27 | export default withRouter( 28 | connect( 29 | mapStateToProps, 30 | mapDispatchToProps 31 | )(Authentication) 32 | ); 33 | -------------------------------------------------------------------------------- /k8s/rtmp-deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | 4 | metadata: 5 | name: rtmp-deployment 6 | labels: 7 | app: rtmp 8 | 9 | spec: 10 | selector: 11 | matchLabels: 12 | app: rtmp 13 | 14 | template: 15 | metadata: 16 | labels: 17 | app: rtmp 18 | 19 | spec: 20 | volumes: 21 | - name: preview-storage 22 | persistentVolumeClaim: 23 | claimName: preview-persistent-volume-claim 24 | containers: 25 | - name: multi-streamer-rtmp 26 | image: praveenraj9495/multi-streamer-rtmp:v0.2.0 27 | volumeMounts: 28 | - name: preview-storage 29 | mountPath: /hls-preview 30 | subPath: hls-preview 31 | env: 32 | - name: REDIS_HOST 33 | value: redis-cluster-ip 34 | - name: GRPC_HOST 35 | value: api-cluster-ip 36 | ports: 37 | - containerPort: 1935 -------------------------------------------------------------------------------- /router/router.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/go-chi/chi" 7 | "github.com/praveen001/go-rtmp-web-server/controllers" 8 | ) 9 | 10 | // CustomRouter wrapped mux router 11 | type CustomRouter struct { 12 | *chi.Mux 13 | *controllers.ApplicationContext 14 | } 15 | 16 | func (cr *CustomRouter) ServeHTTP(w http.ResponseWriter, r *http.Request) { 17 | cr.Mux.ServeHTTP(w, r) 18 | } 19 | 20 | // New initializes the application's router 21 | func New(ctx *controllers.ApplicationContext) http.Handler { 22 | cr := &CustomRouter{ 23 | chi.NewMux(), 24 | ctx, 25 | } 26 | 27 | cr.Use(ctx.CORSHandler, ctx.LogHandler, ctx.RecoveryHandler) 28 | 29 | cr.Mount("/ws", cr.websocketRouter()) 30 | 31 | cr.Route("/v1", func(r chi.Router) { 32 | r.Mount("/api/users", cr.userRouter()) 33 | r.Mount("/api/streams", cr.streamRouter()) 34 | r.Mount("/api/channels", cr.channelRouter()) 35 | }) 36 | 37 | return cr 38 | } 39 | -------------------------------------------------------------------------------- /helm/go-rtmp-web-server/templates/rtmp-deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | 4 | metadata: 5 | name: rtmp-deployment 6 | labels: 7 | deployment: rtmp 8 | 9 | spec: 10 | selector: 11 | matchLabels: 12 | app: rtmp 13 | 14 | template: 15 | metadata: 16 | labels: 17 | app: rtmp 18 | 19 | spec: 20 | volumes: 21 | - name: preview-storage 22 | persistentVolumeClaim: 23 | claimName: preview-persistent-volume-claim 24 | containers: 25 | - name: multi-streamer-rtmp 26 | image: praveenraj9495/multi-streamer-rtmp:v0.2.0 27 | volumeMounts: 28 | - name: preview-storage 29 | mountPath: /hls-preview 30 | subPath: hls-preview 31 | env: 32 | - name: REDIS_HOST 33 | value: redis-cluster-ip 34 | - name: GRPC_HOST 35 | value: api-cluster-ip 36 | ports: 37 | - containerPort: 1935 -------------------------------------------------------------------------------- /k8s/mysql-deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | 4 | metadata: 5 | name: mysql-deployment 6 | labels: 7 | app: mysql-deployment 8 | 9 | spec: 10 | selector: 11 | matchLabels: 12 | app: mysql 13 | 14 | template: 15 | metadata: 16 | labels: 17 | app: mysql 18 | 19 | spec: 20 | volumes: 21 | - name: mysql-storage 22 | persistentVolumeClaim: 23 | claimName: mysql-persistent-volume-claim 24 | 25 | containers: 26 | - name: mysql 27 | image: mysql 28 | volumeMounts: 29 | - name: mysql-storage 30 | mountPath: /var/lib/mysql 31 | subPath: mysql 32 | env: 33 | - name: MYSQL_ROOT_PASSWORD 34 | value: root 35 | - name: MYSQL_DATABASE 36 | value: multi-streamer 37 | - name: MYSQL_USER 38 | value: multi-streamer 39 | - name: MYSQL_PASSWORD 40 | value: multi-streamer -------------------------------------------------------------------------------- /frontend/src/actions/snackbarActions.ts: -------------------------------------------------------------------------------- 1 | import { IToast } from '../reducers/snackbarReducer'; 2 | 3 | export enum SnackbarActionTypes { 4 | SHOW_SNACKBAR = 'snackbar/show', 5 | HIDE_SNACKBAR = 'snackbar/hide' 6 | } 7 | 8 | export function showSnackbar( 9 | message: string, 10 | error: boolean 11 | ): IShowSnackbarAction { 12 | return { 13 | type: SnackbarActionTypes.SHOW_SNACKBAR, 14 | payload: { message, error } 15 | }; 16 | } 17 | 18 | export function hideSnackbar(id: number): IHideSnackbarAction { 19 | return { 20 | type: SnackbarActionTypes.HIDE_SNACKBAR, 21 | payload: { 22 | id 23 | } 24 | }; 25 | } 26 | 27 | export interface IShowSnackbarAction { 28 | type: SnackbarActionTypes.SHOW_SNACKBAR; 29 | payload: Pick; 30 | } 31 | 32 | export interface IHideSnackbarAction { 33 | type: SnackbarActionTypes.HIDE_SNACKBAR; 34 | payload: { 35 | id: number; 36 | }; 37 | } 38 | 39 | export type SnackbarAction = IShowSnackbarAction | IHideSnackbarAction; 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | ``` 3 | go get -u github.com/praveen001/go-rtmp-web-server 4 | ``` 5 | 6 | Just a learning project. 7 | 8 | # RTMP Restreaming Server 9 | 10 | Restreaming server that can stream to Youtube, Twitch and Custom RTMP servers. Like [restream.io](https://restream.io) 11 | 12 | It consists of three components: 13 | * Web server - Provides APIs for authentication, channel listing and creation, etc. 14 | * RTMP restreaming server - RTMP Server that can receive a stream and distribute to multiple endpoints. 15 | * Frontend - User interface. 16 | 17 | ![Screenshot](https://raw.githubusercontent.com/praveen001/go-rtmp-web-server/master/screenshot.png) 18 | 19 | # Running in development mode 20 | 21 | Update `./scripts/configs.env` and run 22 | 23 | ``` 24 | docker-compose up 25 | ``` 26 | 27 | # Running in production mode 28 | 29 | Production mode will run in a kubernetes cluster. 30 | 31 | Configs should be done in `./helm/go-rtmp-web-server/values.yaml` 32 | 33 | ``` 34 | helm install go-restream ./helm/go-rtmp-web-server/ 35 | ``` -------------------------------------------------------------------------------- /frontend/src/components/Icons/Facebook.tsx: -------------------------------------------------------------------------------- 1 | import SvgIcon from '@material-ui/core/SvgIcon'; 2 | import React from 'react'; 3 | 4 | /* tslint:disable */ 5 | export default props => ( 6 | 7 | 12 | 13 | ); 14 | -------------------------------------------------------------------------------- /helm/go-rtmp-web-server/templates/mysql-deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | 4 | metadata: 5 | name: mysql-deployment 6 | labels: 7 | deployment: mysql-deployment 8 | 9 | spec: 10 | selector: 11 | matchLabels: 12 | app: mysql 13 | 14 | template: 15 | metadata: 16 | labels: 17 | app: mysql 18 | 19 | spec: 20 | volumes: 21 | - name: mysql-storage 22 | persistentVolumeClaim: 23 | claimName: mysql-persistent-volume-claim 24 | 25 | containers: 26 | - name: mysql 27 | image: mysql 28 | volumeMounts: 29 | - name: mysql-storage 30 | mountPath: /var/lib/mysql 31 | subPath: mysql 32 | env: 33 | - name: MYSQL_ROOT_PASSWORD 34 | value: root 35 | - name: MYSQL_DATABASE 36 | value: {{ .Values.mysql.database }} 37 | - name: MYSQL_USER 38 | value: {{ .Values.mysql.user }} 39 | - name: MYSQL_PASSWORD 40 | value: {{ .Values.mysql.password }} -------------------------------------------------------------------------------- /frontend/src/reducers/socketReducer.ts: -------------------------------------------------------------------------------- 1 | import produce from 'immer'; 2 | import { SocketActionTypes } from '../actions/socketActions'; 3 | import { ApiStatus } from '../models'; 4 | 5 | export const initialSocketState = { 6 | status: ApiStatus.IN_PROGRESS, 7 | wasConnected: false 8 | }; 9 | 10 | export default function socketReducer(state = initialSocketState, action) { 11 | return produce(state, draft => { 12 | switch (action.type) { 13 | case SocketActionTypes.CONNECT: 14 | draft.status = ApiStatus.IN_PROGRESS; 15 | break; 16 | 17 | case SocketActionTypes.CONNECTED: 18 | draft.status = ApiStatus.SUCCESS; 19 | draft.wasConnected = true; 20 | break; 21 | 22 | case SocketActionTypes.SENT_MESSAGE: 23 | break; 24 | 25 | case SocketActionTypes.DISCONNECTED: 26 | draft.status = ApiStatus.FAILURE; 27 | break; 28 | 29 | default: 30 | } 31 | }); 32 | } 33 | 34 | export interface ISocketState { 35 | status: ApiStatus; 36 | wasConnected: boolean; 37 | } 38 | -------------------------------------------------------------------------------- /frontend/src/theme.ts: -------------------------------------------------------------------------------- 1 | import createMuiTheme, { Theme } from '@material-ui/core/styles/createMuiTheme'; 2 | 3 | export default (type: any) => { 4 | return createMuiTheme({ 5 | palette: { 6 | type: type as 'light' | 'dark', 7 | primary: { 8 | main: '#02444e' 9 | }, 10 | secondary: { 11 | main: '#15ac02' 12 | }, 13 | text: { 14 | primary: type === 'light' ? '#363f45' : '#ddd', 15 | secondary: type === 'light' ? '#525e66' : '#ccc' 16 | }, 17 | error: { 18 | main: '#df041f' 19 | } 20 | }, 21 | transitions: { 22 | duration: { 23 | leavingScreen: 200, 24 | enteringScreen: 200 25 | } 26 | }, 27 | typography: { 28 | useNextVariants: true 29 | } 30 | }); 31 | }; 32 | 33 | /* tslint:disable */ 34 | interface ICustomTheme {} 35 | 36 | declare module '@material-ui/core/styles/createMuiTheme' { 37 | interface Theme extends ICustomTheme {} 38 | interface ThemeOptions extends ICustomTheme {} 39 | } 40 | /* tslint:enable */ 41 | -------------------------------------------------------------------------------- /frontend/src/components/Icons/Twitch.tsx: -------------------------------------------------------------------------------- 1 | import SvgIcon from '@material-ui/core/SvgIcon'; 2 | import React from 'react'; 3 | /* tslint:disable */ 4 | export default props => ( 5 | 6 | 11 | 12 | ); 13 | -------------------------------------------------------------------------------- /frontend/src/reducers/statsReducer.ts: -------------------------------------------------------------------------------- 1 | import produce from 'immer'; 2 | import { StatsAction, StatsActionTypes } from '../actions/statsActions'; 3 | import { defaultStats, IStats } from '../models'; 4 | 5 | export const initialStatsState: IStatsState = { 6 | byId: {} 7 | }; 8 | 9 | export default function statsReducer( 10 | state = initialStatsState, 11 | action: StatsAction 12 | ) { 13 | return produce(state, draft => { 14 | switch (action.type) { 15 | case StatsActionTypes.STREAM_ONLINE: 16 | draft.byId[action.payload.streamId] = defaultStats; 17 | draft.byId[action.payload.streamId].online = true; 18 | break; 19 | 20 | case StatsActionTypes.STREAM_OFFLINE: 21 | draft.byId[action.payload.streamId].online = false; 22 | draft.byId[action.payload.streamId].previewReady = false; 23 | break; 24 | 25 | case StatsActionTypes.PREVIEW_READY: 26 | draft.byId[action.payload.streamId].previewReady = true; 27 | break; 28 | } 29 | }); 30 | } 31 | 32 | export interface IStatsState { 33 | byId: { [key: number]: IStats }; 34 | } 35 | -------------------------------------------------------------------------------- /frontend/src/epics/index.ts: -------------------------------------------------------------------------------- 1 | import { combineEpics, createEpicMiddleware } from 'redux-observable'; 2 | import 'rxjs/add/observable/dom/webSocket'; 3 | import 'rxjs/add/observable/fromPromise'; 4 | import 'rxjs/add/observable/of'; 5 | import 'rxjs/add/operator/catch'; 6 | import 'rxjs/add/operator/debounceTime'; 7 | import 'rxjs/add/operator/delay'; 8 | import 'rxjs/add/operator/do'; 9 | import 'rxjs/add/operator/exhaustMap'; 10 | import 'rxjs/add/operator/map'; 11 | import 'rxjs/add/operator/mapTo'; 12 | import 'rxjs/add/operator/merge'; 13 | import 'rxjs/add/operator/mergeMap'; 14 | import 'rxjs/add/operator/startWith'; 15 | import 'rxjs/add/operator/switchMap'; 16 | import 'rxjs/add/operator/takeUntil'; 17 | import 'rxjs/add/operator/throttleTime'; 18 | import authEpics from './authEpics'; 19 | import channelEpics from './channelEpics'; 20 | import snackbarEpics from './snackbarEpics'; 21 | import socketEpics from './socketEpics'; 22 | import streamEpics from './streamEpics'; 23 | 24 | export const rootEpic = combineEpics( 25 | authEpics, 26 | channelEpics, 27 | streamEpics, 28 | socketEpics, 29 | snackbarEpics 30 | ); 31 | 32 | export default createEpicMiddleware(); 33 | -------------------------------------------------------------------------------- /frontend/src/components/Player/Player.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import videojs from 'video.js'; 3 | import 'video.js/dist/video-js.css'; 4 | import 'videojs-contrib-hls'; 5 | 6 | export default class VideoPlayer extends React.Component { 7 | videoNode; 8 | player; 9 | 10 | componentDidMount() { 11 | // instantiate Video.js 12 | this.player = videojs(this.videoNode, this.props, function onPlayerReady() { 13 | // 14 | }); 15 | } 16 | 17 | // destroy player on unmount 18 | componentWillUnmount() { 19 | if (this.player) { 20 | this.player.dispose(); 21 | } 22 | } 23 | 24 | // wrap the player in a div with a `data-vjs-player` attribute 25 | // so videojs won't create additional wrapper in the DOM 26 | // see https://github.com/videojs/video.js/pull/3856 27 | render() { 28 | return ( 29 |
30 |
31 |
37 |
38 | ); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /frontend/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { Provider } from 'react-redux'; 4 | import { applyMiddleware, createStore } from 'redux'; 5 | import { composeWithDevTools } from 'redux-devtools-extension'; 6 | 7 | import CssBaseline from '@material-ui/core/CssBaseline'; 8 | import { routerMiddleware } from 'connected-react-router'; 9 | import App from './components/App'; 10 | import ThemeContainer from './containers/ThemeContainer'; 11 | import epicMiddleware, { rootEpic } from './epics'; 12 | import rootReducer, { history, initialState } from './reducers'; 13 | 14 | const composeEnhancer = composeWithDevTools({ 15 | name: 'stream' 16 | }); 17 | 18 | export const store = createStore( 19 | rootReducer, 20 | initialState, 21 | composeEnhancer(applyMiddleware(epicMiddleware, routerMiddleware(history))) 22 | ); 23 | 24 | epicMiddleware.run(rootEpic); 25 | 26 | ReactDOM.render( 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | , 35 | document.getElementById('app') 36 | ); 37 | -------------------------------------------------------------------------------- /frontend/src/reducers/snackbarReducer.ts: -------------------------------------------------------------------------------- 1 | import produce from 'immer'; 2 | import { 3 | SnackbarAction, 4 | SnackbarActionTypes 5 | } from '../actions/snackbarActions'; 6 | 7 | export const initialSnackbarState: ISnackbarState = { 8 | byId: {}, 9 | ids: [] 10 | }; 11 | 12 | export default function snackbarReducer( 13 | state: ISnackbarState = initialSnackbarState, 14 | action: SnackbarAction 15 | ) { 16 | return produce(state, draft => { 17 | switch (action.type) { 18 | case SnackbarActionTypes.SHOW_SNACKBAR: 19 | draft.byId[draft.ids.length] = { 20 | id: draft.ids.length, 21 | ...action.payload 22 | }; 23 | draft.ids.push(draft.ids.length); 24 | break; 25 | 26 | case SnackbarActionTypes.HIDE_SNACKBAR: 27 | draft.ids.splice(draft.ids.indexOf(action.payload.id), 1); 28 | delete draft.byId[action.payload.id]; 29 | break; 30 | } 31 | }); 32 | } 33 | 34 | export interface ISnackbarState { 35 | byId: { 36 | [key: string]: IToast; 37 | }; 38 | ids: number[]; 39 | } 40 | 41 | export interface IToast { 42 | id: number; 43 | message: string; 44 | error: boolean; 45 | } 46 | -------------------------------------------------------------------------------- /frontend/src/components/StreamStatusIcon/StreamStatusIcon.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createStyles, 3 | Theme, 4 | withStyles, 5 | WithStyles 6 | } from '@material-ui/core/styles'; 7 | import classnames from 'classnames'; 8 | import React from 'react'; 9 | 10 | const styles = (theme: Theme) => 11 | createStyles({ 12 | status: { 13 | width: 20, 14 | height: 20, 15 | borderRadius: 30, 16 | marginRight: theme.spacing.unit 17 | }, 18 | offline: { 19 | background: '#888' 20 | }, 21 | online: { 22 | background: 'green' 23 | } 24 | }); 25 | 26 | class StreamStatusIcon extends React.Component { 27 | render() { 28 | const { classes, online } = this.props; 29 | 30 | return ( 31 |
37 | ); 38 | } 39 | } 40 | 41 | export default withStyles(styles)(StreamStatusIcon); 42 | 43 | interface IStreamStatusIconOwnProps { 44 | online: boolean; 45 | } 46 | 47 | type StreamStatusIconProps = IStreamStatusIconOwnProps & 48 | WithStyles; 49 | -------------------------------------------------------------------------------- /frontend/src/containers/DashboardContainer.ts: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | 3 | // Types 4 | import Dashboard, { 5 | IDispatchProps, 6 | IStateProps 7 | } from '../components/Dashboard/Dashboard'; 8 | import { IState } from '../reducers'; 9 | 10 | // Actions 11 | import { 12 | addChannel, 13 | deleteChannel, 14 | fetchChannelList, 15 | showAddChannel 16 | } from '../actions/channelActions'; 17 | import { showSnackbar } from '../actions/snackbarActions'; 18 | import { selectStream } from '../actions/streamActions'; 19 | import { defaultStats, IStats } from '../models'; 20 | 21 | function mapStateToProps(state: IState): IStateProps { 22 | return { 23 | token: state.auth.token, 24 | stream: state.streams.byId[state.streams.streamId], 25 | stats: state.stats.byId[state.streams.streamId] || defaultStats, 26 | channels: state.channels.ids.map(id => state.channels.byId[id]) 27 | }; 28 | } 29 | 30 | const mapDispatchToProps: IDispatchProps = { 31 | showAddChannel, 32 | fetchChannelList, 33 | selectStream, 34 | deleteChannel, 35 | showSnackbar 36 | }; 37 | 38 | export default connect( 39 | mapStateToProps, 40 | mapDispatchToProps 41 | )(Dashboard); 42 | -------------------------------------------------------------------------------- /frontend/src/components/Socket/Socket.tsx: -------------------------------------------------------------------------------- 1 | import CircularProgress from '@material-ui/core/CircularProgress'; 2 | import React, { useEffect } from 'react'; 3 | import { useDispatch, useSelector } from 'react-redux'; 4 | import { connect, disconnect } from '../../actions/socketActions'; 5 | import { SocketUrl } from '../../config'; 6 | import { ApiStatus } from '../../models'; 7 | import { IState } from '../../reducers'; 8 | 9 | const WebSocket: React.FC = props => { 10 | const dispatch = useDispatch(); 11 | const { status, wasConnected, token } = useSelector((state: IState) => ({ 12 | status: state.socket.status, 13 | wasConnected: state.socket.wasConnected, 14 | token: state.auth.token 15 | })); 16 | 17 | useEffect(() => { 18 | dispatch(connect(`${SocketUrl}/ws/connect?token=${token}`)); 19 | return () => dispatch(disconnect()); 20 | }, []); 21 | 22 | if (status === ApiStatus.IN_PROGRESS) { 23 | return ; 24 | } 25 | if (status === ApiStatus.FAILURE) { 26 | return
Unable to connect to websocket server
; 27 | } 28 | if (status === ApiStatus.SUCCESS) { 29 | return <>{props.children}; 30 | } 31 | return null; 32 | }; 33 | 34 | export default WebSocket; 35 | -------------------------------------------------------------------------------- /controllers/redis.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/gomodule/redigo/redis" 8 | ) 9 | 10 | // PubSub .. 11 | type PubSub struct { 12 | *redis.PubSubConn 13 | } 14 | 15 | // NewPubSub .. 16 | func NewPubSub(redisConn redis.Conn, channel string) *PubSub { 17 | pubsub := &PubSub{ 18 | &redis.PubSubConn{ 19 | Conn: redisConn, 20 | }, 21 | } 22 | 23 | if pubsub.PubSubConn.Subscribe(channel) != nil { 24 | panic("Unable to subscribe to redis") 25 | } 26 | 27 | return pubsub 28 | } 29 | 30 | // Listen will read messages published to the subscribed channel 31 | func (ps *PubSub) Listen(hub *WSHub) { 32 | var t map[string]interface{} 33 | 34 | for { 35 | raw := ps.PubSubConn.Receive() 36 | switch raw.(type) { 37 | case redis.Subscription: 38 | case redis.Message: 39 | if err := json.Unmarshal(raw.(redis.Message).Data, &t); err != nil { 40 | fmt.Println("Unable to unmarshal redis message") 41 | continue 42 | } 43 | 44 | payload, err := json.Marshal(t["Message"]) 45 | if err != nil { 46 | fmt.Println("Unable to marshal redis message payload") 47 | continue 48 | } 49 | 50 | outgoing := WSOutgoing{ 51 | ID: int(t["ID"].(float64)), 52 | Message: payload, 53 | } 54 | hub.Outgoing <- outgoing 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /docker/multi-streamer-ui/nginx.conf: -------------------------------------------------------------------------------- 1 | user nginx; 2 | worker_processes 1; 3 | 4 | error_log /var/log/nginx/error.log warn; 5 | pid /var/run/nginx.pid; 6 | 7 | 8 | events { 9 | worker_connections 1024; 10 | } 11 | 12 | 13 | http { 14 | include /etc/nginx/mime.types; 15 | default_type application/octet-stream; 16 | 17 | log_format main '$remote_addr - $remote_user [$time_local] "$request" ' 18 | '$status $body_bytes_sent "$http_referer" ' 19 | '"$http_user_agent" "$http_x_forwarded_for"'; 20 | 21 | access_log /var/log/nginx/access.log main; 22 | 23 | sendfile on; 24 | #tcp_nopush on; 25 | 26 | keepalive_timeout 65; 27 | 28 | #gzip on; 29 | 30 | # include /etc/nginx/conf.d/*.conf; 31 | 32 | server { 33 | listen 80; 34 | server_name _; 35 | 36 | location /v1/api/ws/ { 37 | proxy_http_version 1.1; 38 | proxy_set_header Upgrade $http_upgrade; 39 | proxy_set_header Connection "upgrade"; 40 | proxy_pass http://localhost:5000; 41 | } 42 | 43 | location /v1/api/ { 44 | proxy_pass http://localhost:5000; 45 | } 46 | 47 | root /usr/share/nginx/html; 48 | index index.html; 49 | try_files $uri $uri/ /index.html =404; 50 | } 51 | } -------------------------------------------------------------------------------- /k8s/api-deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | 4 | metadata: 5 | name: api-deployment 6 | labels: 7 | app: api 8 | 9 | spec: 10 | selector: 11 | matchLabels: 12 | app: api 13 | 14 | template: 15 | metadata: 16 | labels: 17 | app: api 18 | 19 | spec: 20 | containers: 21 | - name: multi-streamer-api 22 | image: praveenraj9495/multi-streamer-api:v0.2.0 23 | env: 24 | - name: API_ENDPOINT 25 | value: http://localhost 26 | - name: UI_ENDPOINT 27 | value: http://localhost 28 | - name: MYSQL_USER 29 | value: multi-streamer 30 | - name: MYSQL_PASSWORD 31 | value: multi-streamer 32 | - name: MYSQL_HOST 33 | value: mysql-cluster-ip 34 | - name: MYSQL_DATABASE 35 | value: multi-streamer 36 | - name: REDIS_HOST 37 | value: redis-cluster-ip 38 | - name: YOUTUBE_CLIENT_ID 39 | value: 813004830141-3tamleghb88jb8c38pjdfbobp2mg7qt3.apps.googleusercontent.com 40 | - name: YOUTUBE_CLIENT_SECRET 41 | value: 42 | - name: TWITCH_CLIENT_ID 43 | value: d72o1jp8j07z6z3km9zxaz266us9lx 44 | - name: TWITCH_CLIENT_SECRET 45 | value: mc6tzwdmpbapslalifmzycgpkn011w 46 | ports: 47 | - containerPort: 5000 48 | - containerPort: 4005 -------------------------------------------------------------------------------- /frontend/src/actions/statsActions.ts: -------------------------------------------------------------------------------- 1 | export enum StatsActionTypes { 2 | STREAM_ONLINE = 'stats/streamOnline', 3 | STREAM_OFFLINE = 'stats/streamOffline', 4 | PREVIEW_READY = 'stats/previewReady' 5 | } 6 | 7 | export function streamOnline(streamId: number): IStreamOnlineAction { 8 | return { 9 | type: StatsActionTypes.STREAM_ONLINE, 10 | payload: { 11 | streamId 12 | } 13 | }; 14 | } 15 | 16 | export function streamOffline(streamId: number): IStreamOfflineAction { 17 | return { 18 | type: StatsActionTypes.STREAM_OFFLINE, 19 | payload: { 20 | streamId 21 | } 22 | }; 23 | } 24 | 25 | export function previewReady(streamId: number): IPreviewReadyAction { 26 | return { 27 | type: StatsActionTypes.PREVIEW_READY, 28 | payload: { 29 | streamId 30 | } 31 | }; 32 | } 33 | 34 | export interface IStreamOnlineAction { 35 | type: StatsActionTypes.STREAM_ONLINE; 36 | payload: { 37 | streamId: number; 38 | }; 39 | } 40 | 41 | export interface IStreamOfflineAction { 42 | type: StatsActionTypes.STREAM_OFFLINE; 43 | payload: { 44 | streamId: number; 45 | }; 46 | } 47 | 48 | export interface IPreviewReadyAction { 49 | type: StatsActionTypes.PREVIEW_READY; 50 | payload: { 51 | streamId: number; 52 | }; 53 | } 54 | 55 | export type StatsAction = 56 | | IStreamOnlineAction 57 | | IStreamOfflineAction 58 | | IPreviewReadyAction; 59 | -------------------------------------------------------------------------------- /frontend/webpack.prod.js: -------------------------------------------------------------------------------- 1 | const path = require('path'), 2 | HtmlWebpackPlugin = require('html-webpack-plugin'), 3 | BundleAnalyzerPlugin = require('webpack-bundle-analyzer') 4 | .BundleAnalyzerPlugin, 5 | webpack = require('webpack'); 6 | 7 | module.exports = { 8 | mode: 'production', 9 | 10 | entry: './src/index.tsx', 11 | 12 | resolve: { 13 | extensions: ['.ts', '.tsx', '.js', '.jsx'] 14 | }, 15 | 16 | output: { 17 | path: path.resolve(__dirname, 'dist'), 18 | filename: 'bundle.js', 19 | publicPath: '/' 20 | }, 21 | 22 | module: { 23 | rules: [ 24 | { 25 | test: /\.tsx?$/, 26 | loader: 'awesome-typescript-loader' 27 | }, 28 | { 29 | test: /\.css$/, 30 | use: ['style-loader', 'css-loader'] 31 | }, 32 | { 33 | test: /\.(woff(2)?|ttf|eot|svg)(\?v=\d+\.\d+\.\d+)?$/, 34 | use: [ 35 | { 36 | loader: 'file-loader', 37 | options: { 38 | name: '[name].[ext]', 39 | outputPath: 'fonts/' 40 | } 41 | } 42 | ] 43 | } 44 | ] 45 | }, 46 | 47 | plugins: [ 48 | new HtmlWebpackPlugin({ 49 | template: './src/index.html' 50 | }), 51 | // new BundleAnalyzerPlugin(), 52 | new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/), 53 | new webpack.HotModuleReplacementPlugin() 54 | ] 55 | }; 56 | -------------------------------------------------------------------------------- /helm/go-rtmp-web-server/templates/api-deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | 4 | metadata: 5 | name: api-deployment 6 | labels: 7 | deployment: api 8 | 9 | spec: 10 | selector: 11 | matchLabels: 12 | app: api 13 | 14 | template: 15 | metadata: 16 | labels: 17 | app: api 18 | 19 | spec: 20 | containers: 21 | - name: multi-streamer-api 22 | image: praveenraj9495/multi-streamer-api:v0.2.0 23 | env: 24 | - name: API_ENDPOINT 25 | value: {{ .Values.endpoint.api }} 26 | - name: UI_ENDPOINT 27 | value: {{ .Values.endpoint.ui }} 28 | - name: MYSQL_USER 29 | value: {{ .Values.mysql.user }} 30 | - name: MYSQL_PASSWORD 31 | value: {{ .Values.mysql.password }} 32 | - name: MYSQL_HOST 33 | value: mysql-cluster-ip 34 | - name: MYSQL_DATABASE 35 | value: {{ .Values.mysql.database }} 36 | - name: REDIS_HOST 37 | value: redis-cluster-ip 38 | - name: YOUTUBE_CLIENT_ID 39 | value: {{ .Values.youtube.client_id }} 40 | - name: YOUTUBE_CLIENT_SECRET 41 | value: {{ .Values.youtube.client_secret }} 42 | - name: TWITCH_CLIENT_ID 43 | value: {{ .Values.twitch.client_id }} 44 | - name: TWITCH_CLIENT_SECRET 45 | value: {{ .Values.twitch.client_secret }} 46 | ports: 47 | - containerPort: 5000 48 | - containerPort: 4005 -------------------------------------------------------------------------------- /scripts/nginx/nginx.conf: -------------------------------------------------------------------------------- 1 | user nginx; 2 | worker_processes 1; 3 | 4 | error_log /var/log/nginx/error.log warn; 5 | pid /var/run/nginx.pid; 6 | 7 | 8 | events { 9 | worker_connections 1024; 10 | } 11 | 12 | 13 | http { 14 | include /etc/nginx/mime.types; 15 | default_type application/octet-stream; 16 | 17 | log_format main '$remote_addr - $remote_user [$time_local] "$request" ' 18 | '$status $body_bytes_sent "$http_referer" ' 19 | '"$http_user_agent" "$http_x_forwarded_for"'; 20 | 21 | access_log /var/log/nginx/access.log main; 22 | 23 | sendfile on; 24 | #tcp_nopush on; 25 | 26 | keepalive_timeout 65; 27 | 28 | #gzip on; 29 | 30 | # include /etc/nginx/conf.d/*.conf; 31 | 32 | server { 33 | listen 80; 34 | server_name _; 35 | root /usr/share/nginx/html; 36 | 37 | location /ws/ { 38 | proxy_http_version 1.1; 39 | proxy_set_header Upgrade $http_upgrade; 40 | proxy_set_header Connection "upgrade"; 41 | proxy_pass http://web:5000; 42 | } 43 | 44 | location /v1/api/ { 45 | proxy_pass http://web:5000; 46 | } 47 | 48 | location /hls-preview/ { 49 | index index.html; 50 | try_files $uri $uri/ /index.html =404; 51 | } 52 | 53 | location / { 54 | proxy_pass http://ui:8080; 55 | } 56 | } 57 | } -------------------------------------------------------------------------------- /frontend/src/components/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Redirect, Route, Switch } from 'react-router-dom'; 3 | 4 | // Material-UI Components 5 | import { 6 | createStyles, 7 | Theme, 8 | withStyles, 9 | WithStyles 10 | } from '@material-ui/core/styles'; 11 | 12 | // Components/Containers 13 | import { ConnectedRouter } from 'connected-react-router'; 14 | import AuthenticationContainer from '../containers/AuthenticationContainer'; 15 | import SnackbarContainer from '../containers/SnackbarContainer'; 16 | import { history } from '../reducers'; 17 | import LandingPage from './LandingPage/LandingPage'; 18 | 19 | const styles = (theme: Theme) => 20 | createStyles({ 21 | content: { 22 | padding: theme.spacing.unit * 2, 23 | height: '100%', 24 | width: '100%' 25 | } 26 | }); 27 | 28 | class App extends React.Component { 29 | render() { 30 | const { classes } = this.props; 31 | 32 | return ( 33 | 34 |
35 | 36 | 37 | 38 | 39 | 40 | 41 |
42 |
43 | ); 44 | } 45 | } 46 | 47 | export default withStyles(styles)(App); 48 | 49 | export type AppProps = WithStyles; 50 | -------------------------------------------------------------------------------- /controllers/rpc.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net" 7 | 8 | "github.com/praveen001/go-rtmp-grpc/pkg/api/v1" 9 | "github.com/praveen001/go-rtmp-web-server/models" 10 | "google.golang.org/grpc" 11 | ) 12 | 13 | // UserChannelServiceServer .. 14 | type UserChannelServiceServer struct { 15 | AppContext *ApplicationContext 16 | } 17 | 18 | // Get .. 19 | func (s *UserChannelServiceServer) Get(ctx context.Context, r *v1.GetUserChannelRequest) (*v1.GetUserChannelResponse, error) { 20 | stream := &models.Stream{ 21 | Key: r.GetStreamKey(), 22 | } 23 | s.AppContext.DB.Find(stream, stream).Related(&stream.Channels) 24 | 25 | var channels []*v1.UserChannel 26 | for _, c := range stream.Channels { 27 | ch := &v1.UserChannel{ 28 | Name: c.Name, 29 | URL: c.URL, 30 | Key: c.Key, 31 | Enabled: c.Enabled, 32 | ID: int64(c.ID), 33 | } 34 | channels = append(channels, ch) 35 | } 36 | 37 | return &v1.GetUserChannelResponse{User: &v1.User{ID: int64(stream.UserID)}, StreamID: int64(stream.ID), Channels: channels}, nil 38 | } 39 | 40 | // NewRPCServer .. 41 | func NewRPCServer(appContext *ApplicationContext) { 42 | listener, err := net.Listen("tcp", fmt.Sprintf("0.0.0.0:4005")) 43 | if err != nil { 44 | fmt.Println("Error listening") 45 | return 46 | } 47 | 48 | rpcServer := grpc.NewServer() 49 | v1.RegisterUserChannelServiceServer(rpcServer, &UserChannelServiceServer{ 50 | AppContext: appContext, 51 | }) 52 | rpcServer.Serve(listener) 53 | } 54 | -------------------------------------------------------------------------------- /frontend/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'), 2 | HtmlWebpackPlugin = require('html-webpack-plugin'), 3 | BundleAnalyzerPlugin = require('webpack-bundle-analyzer') 4 | .BundleAnalyzerPlugin, 5 | webpack = require('webpack'); 6 | 7 | module.exports = { 8 | mode: 'development', 9 | 10 | entry: './src/index.tsx', 11 | 12 | resolve: { 13 | extensions: ['.ts', '.tsx', '.js', '.jsx'] 14 | }, 15 | 16 | devtool: 'inline-source-map', 17 | 18 | devServer: { 19 | historyApiFallback: true, 20 | disableHostCheck: true 21 | }, 22 | 23 | output: { 24 | path: path.resolve(__dirname, 'dist'), 25 | filename: 'bundle.js', 26 | publicPath: '/' 27 | }, 28 | 29 | module: { 30 | rules: [ 31 | { 32 | test: /\.tsx?$/, 33 | loader: 'awesome-typescript-loader' 34 | }, 35 | { 36 | test: /\.css$/, 37 | use: ['style-loader', 'css-loader'] 38 | }, 39 | { 40 | test: /\.(woff(2)?|ttf|eot|svg)(\?v=\d+\.\d+\.\d+)?$/, 41 | use: [ 42 | { 43 | loader: 'file-loader', 44 | options: { 45 | name: '[name].[ext]', 46 | outputPath: 'fonts/' 47 | } 48 | } 49 | ] 50 | } 51 | ] 52 | }, 53 | 54 | plugins: [ 55 | new HtmlWebpackPlugin({ 56 | template: './src/index.html' 57 | }), 58 | // new BundleAnalyzerPlugin(), 59 | new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/), 60 | new webpack.HotModuleReplacementPlugin() 61 | ] 62 | }; 63 | -------------------------------------------------------------------------------- /scripts/web/.air.toml: -------------------------------------------------------------------------------- 1 | # Config file for [Air](https://github.com/cosmtrek/air) in TOML format 2 | 3 | # Working directory 4 | # . or absolute path, please note that the directories following must be under root. 5 | root = "." 6 | tmp_dir = "tmp" 7 | 8 | [build] 9 | # Just plain old shell command. You could use `make` as well. 10 | cmd = "go build ." 11 | # Binary file yields from `cmd`. 12 | bin = "go-rtmp-web-server web" 13 | # Customize binary. 14 | full_bin = "" 15 | # Watch these filename extensions. 16 | include_ext = ["go", "tpl", "tmpl", "html"] 17 | # Ignore these filename extensions or directories. 18 | exclude_dir = ["tmp", "frontend", "rtmp", "amf"] 19 | # Watch these directories if you specified. 20 | include_dir = [] 21 | # Exclude files. 22 | exclude_file = [] 23 | # Exclude specific regular expressions. 24 | exclude_regex = ["_test.go"] 25 | # Exclude unchanged files. 26 | exclude_unchanged = true 27 | # Follow symlink for directories 28 | follow_symlink = true 29 | # This log file places in your tmp_dir. 30 | log = "air.log" 31 | # It's not necessary to trigger build each time file changes if it's too frequent. 32 | delay = 1000 # ms 33 | # Stop running old binary when build errors occur. 34 | stop_on_error = true 35 | # Send Interrupt signal before killing process (windows does not support this feature) 36 | send_interrupt = false 37 | # Delay after sending Interrupt signal 38 | kill_delay = 500 # ms 39 | 40 | [log] 41 | # Show log time 42 | time = false 43 | 44 | [color] 45 | # Customize each part's color. If no color found, use the raw app log. 46 | main = "magenta" 47 | watcher = "cyan" 48 | build = "yellow" 49 | runner = "green" 50 | 51 | [misc] 52 | # Delete tmp directory on exit 53 | clean_on_exit = true -------------------------------------------------------------------------------- /k8s/ingress-service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: networking.k8s.io/v1 2 | kind: Ingress 3 | 4 | metadata: 5 | name: ingress-service 6 | annotations: 7 | kubernetes.io/ingress.class: 'nginx' 8 | nginx.ingress.kubernetes.io/server-snippets: | 9 | location /ws { 10 | proxy_set_header Upgrade $http_upgrade; 11 | proxy_http_version 1.1; 12 | proxy_set_header X-Forwarded-Host $http_host; 13 | proxy_set_header X-Forwarded-Proto $scheme; 14 | proxy_set_header X-Forwarded-For $remote_addr; 15 | proxy_set_header Host $host; 16 | proxy_set_header Connection "upgrade"; 17 | proxy_cache_bypass $http_upgrade; 18 | } 19 | 20 | spec: 21 | rules: 22 | - http: 23 | paths: 24 | - path: /v1/api 25 | pathType: Prefix 26 | backend: 27 | service: 28 | name: api-cluster-ip 29 | port: 30 | number: 5000 31 | - path: /ws 32 | pathType: Prefix 33 | backend: 34 | service: 35 | name: api-cluster-ip 36 | port: 37 | number: 5000 38 | - path: /hls-preview 39 | pathType: Prefix 40 | backend: 41 | service: 42 | name: frontend-cluster-ip 43 | port: 44 | number: 80 45 | - path: / 46 | pathType: Prefix 47 | backend: 48 | service: 49 | name: frontend-cluster-ip 50 | port: 51 | number: 80 52 | 53 | --- 54 | 55 | apiVersion: v1 56 | kind: Service 57 | 58 | metadata: 59 | name: rtmp-ingress 60 | 61 | spec: 62 | type: LoadBalancer 63 | selector: 64 | app: rtmp 65 | ports: 66 | - port: 1935 67 | targetPort: 1935 -------------------------------------------------------------------------------- /scripts/rtmp/.air.toml: -------------------------------------------------------------------------------- 1 | # Config file for [Air](https://github.com/cosmtrek/air) in TOML format 2 | 3 | # Working directory 4 | # . or absolute path, please note that the directories following must be under root. 5 | root = "." 6 | tmp_dir = "tmp" 7 | 8 | [build] 9 | # Just plain old shell command. You could use `make` as well. 10 | cmd = "go build ." 11 | # Binary file yields from `cmd`. 12 | bin = "go-rtmp-web-server rtmp" 13 | # Customize binary. 14 | full_bin = "" 15 | # Watch these filename extensions. 16 | include_ext = ["go", "tpl", "tmpl", "html"] 17 | # Ignore these filename extensions or directories. 18 | exclude_dir = ["tmp", "frontend", "controllers", "router", "models"] 19 | # Watch these directories if you specified. 20 | include_dir = [] 21 | # Exclude files. 22 | exclude_file = [] 23 | # Exclude specific regular expressions. 24 | exclude_regex = ["_test.go"] 25 | # Exclude unchanged files. 26 | exclude_unchanged = true 27 | # Follow symlink for directories 28 | follow_symlink = true 29 | # This log file places in your tmp_dir. 30 | log = "air.log" 31 | # It's not necessary to trigger build each time file changes if it's too frequent. 32 | delay = 1000 # ms 33 | # Stop running old binary when build errors occur. 34 | stop_on_error = true 35 | # Send Interrupt signal before killing process (windows does not support this feature) 36 | send_interrupt = false 37 | # Delay after sending Interrupt signal 38 | kill_delay = 500 # ms 39 | 40 | [log] 41 | # Show log time 42 | time = false 43 | 44 | [color] 45 | # Customize each part's color. If no color found, use the raw app log. 46 | main = "magenta" 47 | watcher = "cyan" 48 | build = "yellow" 49 | runner = "green" 50 | 51 | [misc] 52 | # Delete tmp directory on exit 53 | clean_on_exit = true -------------------------------------------------------------------------------- /frontend/src/reducers/channelReducer.ts: -------------------------------------------------------------------------------- 1 | import produce from 'immer'; 2 | 3 | import { ChannelAction, ChannelActionTypes } from '../actions/channelActions'; 4 | import { IChannel } from '../models'; 5 | 6 | export const initialChannelState: IChannelState = { 7 | loading: false, 8 | byId: {}, 9 | ids: [], 10 | addChannelOpen: false 11 | }; 12 | 13 | export default function channelReducer( 14 | state: IChannelState = initialChannelState, 15 | action: ChannelAction 16 | ): IChannelState { 17 | return produce(state, draft => { 18 | switch (action.type) { 19 | case ChannelActionTypes.FETCHED_CHANNEL_LIST: 20 | action.payload.channels.forEach(channel => { 21 | draft.byId[channel.id] = channel; 22 | draft.ids.push(channel.id); 23 | }); 24 | draft.loading = false; 25 | break; 26 | 27 | case ChannelActionTypes.FETCHING_CHANNEL_LIST: 28 | case ChannelActionTypes.ADDING_CHANNEL: 29 | draft.loading = true; 30 | draft.byId = {}; 31 | draft.ids = []; 32 | break; 33 | 34 | case ChannelActionTypes.SHOW_ADD_CHANNEL: 35 | draft.addChannelOpen = true; 36 | break; 37 | 38 | case ChannelActionTypes.HIDE_ADD_CHANNEL: 39 | draft.addChannelOpen = false; 40 | break; 41 | 42 | case ChannelActionTypes.DELETE_CHANNEL_SUCCESS: 43 | delete draft.byId[action.payload.channelId]; 44 | draft.ids.splice(draft.ids.indexOf(action.payload.channelId), 1); 45 | break; 46 | 47 | default: 48 | break; 49 | } 50 | }); 51 | } 52 | 53 | export interface IChannelState { 54 | byId: { [key: number]: IChannel }; 55 | ids: number[]; 56 | loading: boolean; 57 | addChannelOpen: boolean; 58 | } 59 | -------------------------------------------------------------------------------- /helm/go-rtmp-web-server/templates/ingress-service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: networking.k8s.io/v1 2 | kind: Ingress 3 | 4 | metadata: 5 | name: ingress-service 6 | annotations: 7 | kubernetes.io/ingress.class: 'nginx' 8 | nginx.ingress.kubernetes.io/server-snippets: | 9 | location /ws { 10 | proxy_set_header Upgrade $http_upgrade; 11 | proxy_http_version 1.1; 12 | proxy_set_header X-Forwarded-Host $http_host; 13 | proxy_set_header X-Forwarded-Proto $scheme; 14 | proxy_set_header X-Forwarded-For $remote_addr; 15 | proxy_set_header Host $host; 16 | proxy_set_header Connection "upgrade"; 17 | proxy_cache_bypass $http_upgrade; 18 | } 19 | 20 | spec: 21 | rules: 22 | - http: 23 | paths: 24 | - path: /v1/api 25 | pathType: Prefix 26 | backend: 27 | service: 28 | name: api-cluster-ip 29 | port: 30 | number: 5000 31 | - path: /ws 32 | pathType: Prefix 33 | backend: 34 | service: 35 | name: api-cluster-ip 36 | port: 37 | number: 5000 38 | - path: /hls-preview 39 | pathType: Prefix 40 | backend: 41 | service: 42 | name: frontend-cluster-ip 43 | port: 44 | number: 80 45 | - path: / 46 | pathType: Prefix 47 | backend: 48 | service: 49 | name: frontend-cluster-ip 50 | port: 51 | number: 80 52 | 53 | --- 54 | 55 | apiVersion: v1 56 | kind: Service 57 | 58 | metadata: 59 | name: rtmp-ingress 60 | 61 | spec: 62 | type: LoadBalancer 63 | selector: 64 | app: rtmp 65 | ports: 66 | - port: 1935 67 | targetPort: 1935 -------------------------------------------------------------------------------- /rtmp/server.go: -------------------------------------------------------------------------------- 1 | package rtmp 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "net" 7 | 8 | "github.com/gomodule/redigo/redis" 9 | "github.com/praveen001/go-rtmp-grpc/pkg/api/v1" 10 | ) 11 | 12 | // StreamerContext .. 13 | type StreamerContext struct { 14 | RPC v1.UserChannelServiceClient 15 | Redis redis.Conn 16 | sessions map[string]*Connection 17 | preview chan string 18 | } 19 | 20 | func (ctx *StreamerContext) set(key string, c *Connection) { 21 | ctx.sessions[key] = c 22 | } 23 | 24 | func (ctx *StreamerContext) get(key string) *Connection { 25 | return ctx.sessions[key] 26 | } 27 | 28 | func (ctx *StreamerContext) delete(key string) { 29 | delete(ctx.sessions, key) 30 | } 31 | 32 | // InitServer starts the RTMP server 33 | func InitServer(ctx *StreamerContext) { 34 | ctx.sessions = make(map[string]*Connection) 35 | ctx.preview = make(chan string) 36 | 37 | go InitPreviewServer(ctx) 38 | 39 | listener, err := net.Listen("tcp", ":1935") 40 | if err != nil { 41 | fmt.Printf("Error starting RTMP server %s", err.Error()) 42 | } 43 | fmt.Println("RTMP Server started at port 1935") 44 | 45 | for { 46 | conn, err := listener.Accept() 47 | if err != nil { 48 | fmt.Printf("Unable to accept connection %s", err.Error()) 49 | } 50 | 51 | rtmpConnection := &Connection{ 52 | Conn: conn, 53 | Reader: bufio.NewReader(conn), 54 | Writer: bufio.NewWriter(conn), 55 | ReadBuffer: make([]byte, 5096), 56 | WriteBuffer: make([]byte, 5096), 57 | csMap: make(map[uint32]*rtmpChunk), 58 | ReadMaxChunkSize: 128, 59 | WriteMaxChunkSize: 4096, 60 | Stage: handshakeStage, 61 | Context: ctx, 62 | } 63 | 64 | go rtmpConnection.Serve() 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /frontend/src/components/LandingPage/LandingPage.tsx: -------------------------------------------------------------------------------- 1 | import AppBar from '@material-ui/core/AppBar'; 2 | import Paper from '@material-ui/core/Paper'; 3 | import { 4 | createStyles, 5 | Theme, 6 | withStyles, 7 | WithStyles 8 | } from '@material-ui/core/styles'; 9 | import Tab from '@material-ui/core/Tab'; 10 | import Tabs from '@material-ui/core/Tabs'; 11 | import React from 'react'; 12 | import LoginFormContainer from '../../containers/LoginFormContainer'; 13 | import RegisterFormContainer from '../../containers/RegisterFormContainer'; 14 | 15 | const styles = (theme: Theme) => 16 | createStyles({ 17 | form: { 18 | height: '90%', 19 | display: 'flex', 20 | justifyContent: 'center', 21 | alignItems: 'center' 22 | }, 23 | paper: { 24 | width: 350, 25 | padding: 0 26 | }, 27 | content: { 28 | padding: theme.spacing.unit * 2 29 | } 30 | }); 31 | 32 | class LandingPage extends React.Component> { 33 | state = { 34 | value: 0 35 | }; 36 | 37 | handleChange = (event, value) => { 38 | this.setState({ value }); 39 | }; 40 | 41 | render() { 42 | const { classes } = this.props; 43 | const { value } = this.state; 44 | 45 | return ( 46 |
47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 |
55 | {value === 0 && } 56 | {value === 1 && } 57 |
58 |
59 |
60 | ); 61 | } 62 | } 63 | 64 | export default withStyles(styles)(LandingPage); 65 | -------------------------------------------------------------------------------- /frontend/src/reducers/authReducer.ts: -------------------------------------------------------------------------------- 1 | import { produce } from 'immer'; 2 | import Cookie from 'js-cookie'; 3 | 4 | // Types 5 | import { 6 | AuthenticationAction, 7 | AuthenticationActionTypes 8 | } from '../actions/authenticationActions'; 9 | 10 | const token = localStorage.getItem('token') || ''; 11 | 12 | export const initialAuthenticationState: IAuthenticationState = { 13 | isAuthenticated: false, 14 | loading: token ? true : false, 15 | loaded: false, 16 | token, 17 | user: {} 18 | }; 19 | 20 | export default function authenticationReducer( 21 | state: IAuthenticationState = initialAuthenticationState, 22 | action: AuthenticationAction 23 | ): IAuthenticationState { 24 | return produce(state, draft => { 25 | switch (action.type) { 26 | case AuthenticationActionTypes.AUTHENTICATE_TOKEN: 27 | localStorage.setItem('token', action.payload.token); 28 | Cookie.set('token', action.payload.token); 29 | draft.token = action.payload.token; 30 | break; 31 | 32 | case AuthenticationActionTypes.AUTHENTICATING_TOKEN: 33 | draft.loading = true; 34 | break; 35 | 36 | case AuthenticationActionTypes.AUTHENTICATED_TOKEN: 37 | draft.isAuthenticated = true; 38 | draft.loading = false; 39 | draft.loaded = true; 40 | draft.token = action.payload.token; 41 | draft.user = action.payload.user; 42 | break; 43 | 44 | case AuthenticationActionTypes.LOGOUT: 45 | localStorage.removeItem('token'); 46 | Cookie.remove('token'); 47 | draft.isAuthenticated = false; 48 | draft.user = {}; 49 | draft.token = ''; 50 | break; 51 | 52 | default: 53 | return state; 54 | } 55 | }); 56 | } 57 | 58 | export interface IAuthenticationState { 59 | isAuthenticated: boolean; 60 | loading: boolean; 61 | loaded: boolean; 62 | token: string; 63 | user: any; 64 | } 65 | -------------------------------------------------------------------------------- /frontend/src/epics/streamEpics.ts: -------------------------------------------------------------------------------- 1 | import { Action } from 'redux'; 2 | import { ActionsObservable, combineEpics, Epic } from 'redux-observable'; 3 | import { from, of } from 'rxjs'; 4 | import axios from '../axios'; 5 | 6 | import { showSnackbar } from '../actions/snackbarActions'; 7 | import { 8 | createStreamFailure, 9 | createStreamSuccess, 10 | deleteStreamFailure, 11 | deleteStreamSuccess, 12 | ICreateStreamAction, 13 | IDeleteStreamAction, 14 | ILoadStreamsAction, 15 | loadStreams, 16 | loadStreamsFailure, 17 | loadStreamsSuccess, 18 | StreamAction, 19 | StreamActionTypes 20 | } from '../actions/streamActions'; 21 | 22 | export const loadStreamsEpic: Epic = action$ => 23 | action$ 24 | .ofType(StreamActionTypes.LOAD_STREAMS) 25 | .mergeMap((action: ILoadStreamsAction) => 26 | from(axios.get('/streams/list')) 27 | .mergeMap(response => [loadStreamsSuccess(response.data.data)]) 28 | .catch(() => of(loadStreamsFailure())) 29 | ); 30 | 31 | export const createStreamEpic: Epic = action$ => 32 | action$ 33 | .ofType(StreamActionTypes.CREATE_STREAM) 34 | .mergeMap((action: ICreateStreamAction) => 35 | from(axios.post('/streams/add', action.payload)) 36 | .mergeMap(response => [loadStreams(), createStreamSuccess()]) 37 | .catch(() => of(createStreamFailure())) 38 | ); 39 | 40 | export const deleteStreamEpic = (action$: ActionsObservable) => 41 | action$ 42 | .ofType(StreamActionTypes.DELETE_STREAM) 43 | .mergeMap((action: IDeleteStreamAction) => 44 | from(axios.delete(`/streams/delete?streamId=${action.payload.streamId}`)) 45 | .mergeMap(response => [deleteStreamSuccess(action.payload.streamId)]) 46 | .catch(() => of(deleteStreamFailure(action.payload.streamId))) 47 | ); 48 | 49 | export default combineEpics( 50 | loadStreamsEpic, 51 | createStreamEpic, 52 | deleteStreamEpic 53 | ); 54 | -------------------------------------------------------------------------------- /rtmp/handshake.go: -------------------------------------------------------------------------------- 1 | package rtmp 2 | 3 | import ( 4 | "encoding/binary" 5 | "fmt" 6 | "io" 7 | ) 8 | 9 | /* 10 | handshake will first read C0(1 byte) and C1(1536 bytes) together 11 | 12 | It will then generate S0(1 byte, same as C0), S1(1536 bytes) and S2(1536 bytes) 13 | and send it to the RTMP client. 14 | 15 | It will wait till it gets C2, which should be same as S1 16 | */ 17 | func (c *Connection) handshake() (err error) { 18 | var random [(1 + 1536 + 1536) * 2]byte 19 | 20 | var C0C1C2 = random[:(1 + 1536 + 1536)] 21 | var C0 = C0C1C2[:1] 22 | var C1 = C0C1C2[1 : 1536+1] 23 | var C0C1 = C0C1C2[:1536+1] 24 | var C2 = C0C1C2[1+1536:] 25 | 26 | var S0S1S2 = random[(1 + 1536 + 1536):] 27 | var S0 = S0S1S2[:1] 28 | var S1 = S0S1S2[1 : 1536+1] 29 | var S2 = S0S1S2[1+1536:] 30 | 31 | _, err = io.ReadFull(c.Reader, C0C1) 32 | if err != nil { 33 | fmt.Printf("Error while reading C0C1 %s", err.Error()) 34 | return 35 | } 36 | 37 | if C0[0] != 3 { 38 | fmt.Printf("Unsupported RTMP version %d", C0[0]) 39 | return 40 | } 41 | fmt.Printf("Client requesting version %d\n", C0[0]) 42 | 43 | clientTime := binary.BigEndian.Uint32(C1[:4]) 44 | // serverTime := clientTime 45 | clientVersion := binary.BigEndian.Uint32(C1[4:8]) 46 | // serverVersion := uint32(0x0d0e0a0d) 47 | 48 | fmt.Println("Time", clientTime) 49 | fmt.Println("Flash Version", binary.BigEndian.Uint32(C1[4:8])) 50 | 51 | // S0[0] = 3 52 | if clientVersion != 0 { 53 | // Complex handshake 54 | } else { 55 | copy(S0, C0) 56 | copy(S1, C1) 57 | copy(S2, C2) 58 | } 59 | 60 | _, err = c.Writer.Write(S0S1S2) 61 | if err != nil { 62 | fmt.Printf("Error writing S0S1S2 %s\n", err.Error()) 63 | return 64 | } 65 | if err = c.Writer.Flush(); err != nil { 66 | fmt.Println("Error flushing S0S1S2") 67 | } 68 | 69 | if _, err = io.ReadFull(c.Reader, C2); err != nil { 70 | fmt.Printf("Error reading C2 %s", err.Error()) 71 | return 72 | } 73 | 74 | c.Stage++ 75 | return 76 | } 77 | -------------------------------------------------------------------------------- /frontend/src/reducers/index.ts: -------------------------------------------------------------------------------- 1 | import { connectRouter, LocationChangeAction } from 'connected-react-router'; 2 | import { createBrowserHistory, LocationState } from 'history'; 3 | import { Reducer } from 'react'; 4 | import { combineReducers } from 'redux'; 5 | import authenticationReducer, { 6 | IAuthenticationState, 7 | initialAuthenticationState 8 | } from './authReducer'; 9 | import channelReducer, { 10 | IChannelState, 11 | initialChannelState 12 | } from './channelReducer'; 13 | import headerReducer, { 14 | IHeaderState, 15 | initialHeaderState 16 | } from './headerReducer'; 17 | import snackbarReducer, { 18 | initialSnackbarState, 19 | ISnackbarState 20 | } from './snackbarReducer'; 21 | import socketReducer, { 22 | initialSocketState, 23 | ISocketState 24 | } from './socketReducer'; 25 | import statsReducer, { initialStatsState, IStatsState } from './statsReducer'; 26 | import streamReducer, { 27 | initialStreamState, 28 | IStreamState 29 | } from './streamReducer'; 30 | import themeReducer, { initialThemeState, IThemeState } from './themeReducer'; 31 | 32 | export const history = createBrowserHistory(); 33 | 34 | export interface IState { 35 | theme: IThemeState; 36 | auth: IAuthenticationState; 37 | router: Reducer; 38 | channels: IChannelState; 39 | streams: IStreamState; 40 | socket: ISocketState; 41 | header: IHeaderState; 42 | stats: IStatsState; 43 | snackbar: ISnackbarState; 44 | } 45 | 46 | export const initialState = { 47 | theme: initialThemeState, 48 | auth: initialAuthenticationState, 49 | channels: initialChannelState, 50 | streams: initialStreamState, 51 | socket: initialSocketState, 52 | header: initialHeaderState, 53 | stats: initialStatsState, 54 | snackbar: initialSnackbarState 55 | }; 56 | 57 | export default combineReducers({ 58 | theme: themeReducer, 59 | auth: authenticationReducer, 60 | router: connectRouter(history), 61 | channels: channelReducer, 62 | streams: streamReducer, 63 | socket: socketReducer, 64 | header: headerReducer, 65 | stats: statsReducer, 66 | snackbar: snackbarReducer 67 | }); 68 | -------------------------------------------------------------------------------- /frontend/src/epics/channelEpics.ts: -------------------------------------------------------------------------------- 1 | import { Action } from 'redux'; 2 | import { combineEpics, Epic } from 'redux-observable'; 3 | import { from, of } from 'rxjs'; 4 | import axios from '../axios'; 5 | 6 | import { 7 | addedChannel, 8 | addingChannel, 9 | ChannelActionTypes, 10 | deleteChannelFailure, 11 | deleteChannelSuccess, 12 | fetchChannelList, 13 | fetchedChannelList, 14 | fetchingChannelList, 15 | IAddChannelAction, 16 | IDeleteChannelAction, 17 | IFetchChannelListAction 18 | } from '../actions/channelActions'; 19 | 20 | export const addChannelEpic: Epic = action$ => 21 | action$ 22 | .ofType(ChannelActionTypes.ADD_CHANNEL) 23 | .mergeMap((action: IAddChannelAction) => 24 | from(axios.post('/channels/add', action.payload)) 25 | .mergeMap(() => [addedChannel()]) 26 | .startWith(addingChannel()) 27 | ); 28 | 29 | export const addedChannelEpic: Epic = action$ => 30 | action$.ofType(ChannelActionTypes.ADDED_CHANNEL).mapTo(fetchChannelList('1')); 31 | 32 | export const fetchChannelEpic: Epic = action$ => 33 | action$ 34 | .ofType(ChannelActionTypes.FETCH_CHANNEL_LIST) 35 | .mergeMap((action: IFetchChannelListAction) => 36 | from(axios.get(`/channels/list?streamId=${action.payload.streamId}`)) 37 | .mergeMap(response => [fetchedChannelList(response.data.data)]) 38 | .startWith(fetchingChannelList()) 39 | ); 40 | 41 | export const deleteChannelEpic: Epic = action$ => 42 | action$ 43 | .ofType(ChannelActionTypes.DELETE_CHANNEL) 44 | .mergeMap((action: IDeleteChannelAction) => 45 | from( 46 | axios.delete( 47 | `/channels/delete?streamId=${action.payload.streamId}&channelId=${ 48 | action.payload.channelId 49 | }` 50 | ) 51 | ) 52 | .mergeMap(response => [ 53 | deleteChannelSuccess( 54 | action.payload.streamId, 55 | action.payload.channelId 56 | ) 57 | ]) 58 | .catch(() => of(deleteChannelFailure())) 59 | ); 60 | 61 | export default combineEpics( 62 | addChannelEpic, 63 | addedChannelEpic, 64 | fetchChannelEpic, 65 | deleteChannelEpic 66 | ); 67 | -------------------------------------------------------------------------------- /controllers/stream.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "strconv" 7 | 8 | "github.com/google/uuid" 9 | 10 | "github.com/praveen001/go-rtmp-web-server/models" 11 | ) 12 | 13 | // AddStream creates a new stream for an user 14 | func (c *ApplicationContext) AddStream(w http.ResponseWriter, r *http.Request) { 15 | stream := &models.Stream{} 16 | if err := json.NewDecoder(r.Body).Decode(stream); err != nil { 17 | c.NewResponse(w).Status(400).SendJSON(Response{ 18 | Error: true, 19 | Message: "Unable to parse request", 20 | }) 21 | return 22 | } 23 | 24 | stream.Key = uuid.New().String() 25 | stream.UserID = int(r.Context().Value("id").(float64)) 26 | 27 | c.DB.Save(stream) 28 | 29 | c.NewResponse(w).Status(200).SendJSON(Response{ 30 | Message: "Stream created successfully", 31 | }) 32 | } 33 | 34 | // ListStreams gives the list of stream that belongs to the user 35 | func (c *ApplicationContext) ListStreams(w http.ResponseWriter, r *http.Request) { 36 | var streams []models.Stream 37 | userID := int(r.Context().Value("id").(float64)) 38 | 39 | c.DB.Find(&streams, models.Stream{UserID: userID}) 40 | 41 | c.NewResponse(w).Status(200).SendJSON(Response{ 42 | Message: "Retrieved stream list successfully", 43 | Data: streams, 44 | }) 45 | } 46 | 47 | // DeleteStream will remove a stream by ID 48 | func (c *ApplicationContext) DeleteStream(w http.ResponseWriter, r *http.Request) { 49 | streamID, err := strconv.Atoi(r.FormValue("streamId")) 50 | if err != nil { 51 | c.NewResponse(w).Status(400).SendJSON(Response{ 52 | Error: true, 53 | Message: "Invalid stream ID", 54 | }) 55 | return 56 | } 57 | 58 | // Access check 59 | stream := &models.Stream{ 60 | Model: models.Model{ 61 | ID: streamID, 62 | }, 63 | } 64 | c.DB.Find(&stream, stream) 65 | if stream.UserID != int(r.Context().Value("id").(float64)) { 66 | c.NewResponse(w).Status(403).SendJSON(Response{ 67 | Error: true, 68 | Message: "Unauthorized", 69 | }) 70 | return 71 | } 72 | 73 | c.DB.Where("id = ?", streamID).Delete(models.Stream{}) 74 | 75 | c.NewResponse(w).Status(200).SendJSON(Response{ 76 | Message: "Deleted stream successfully", 77 | }) 78 | } 79 | -------------------------------------------------------------------------------- /frontend/src/epics/socketEpics.ts: -------------------------------------------------------------------------------- 1 | import { Action } from 'redux'; 2 | import { ActionsObservable, combineEpics } from 'redux-observable'; 3 | import { of } from 'rxjs'; 4 | import { Subject } from 'rxjs/Subject'; 5 | import { webSocket, WebSocketSubject } from 'rxjs/webSocket'; 6 | import { 7 | connect, 8 | connected, 9 | disconnected, 10 | IConnectAction, 11 | ISendMessageAction, 12 | receiveMessage, 13 | sentMessage, 14 | SocketActionTypes 15 | } from '../actions/socketActions'; 16 | 17 | let webSocketSubject: WebSocketSubject; 18 | let onOpenSubject = new Subject(); 19 | let onCloseSubject = new Subject(); 20 | 21 | const connectSocket = websocketUrl => { 22 | onOpenSubject = new Subject(); 23 | onCloseSubject = new Subject(); 24 | webSocketSubject = webSocket({ 25 | url: websocketUrl, 26 | openObserver: onOpenSubject, 27 | closeObserver: onCloseSubject 28 | }); 29 | return webSocketSubject; 30 | }; 31 | 32 | const connectEpic = (action$: ActionsObservable) => 33 | action$ 34 | .ofType(SocketActionTypes.CONNECT) 35 | .switchMap((action: IConnectAction) => 36 | connectSocket(action.payload.url) 37 | .map(data => { 38 | const { type, ...payload } = data; 39 | return { 40 | type, 41 | payload 42 | }; 43 | }) 44 | .catch(() => 45 | of(connect(action.payload.url)) 46 | .delay(5000) 47 | .startWith(disconnected()) 48 | ) 49 | ); 50 | 51 | const connectedEpic = (action$: ActionsObservable) => 52 | action$ 53 | .ofType(SocketActionTypes.CONNECT) 54 | .switchMap(() => onOpenSubject.mapTo(connected())); 55 | 56 | const sendMessageEpic = (action$: ActionsObservable) => 57 | action$ 58 | .ofType(SocketActionTypes.SEND_MESSAGE) 59 | .map((action: ISendMessageAction) => { 60 | webSocketSubject.next(action.payload.msg); 61 | return sentMessage(); 62 | }); 63 | 64 | const disconnectEpic = (action$: ActionsObservable) => 65 | action$.ofType(SocketActionTypes.DISCONNECT).map(() => { 66 | webSocketSubject.complete(); 67 | return disconnected(); 68 | }); 69 | 70 | export default combineEpics( 71 | connectEpic, 72 | connectedEpic, 73 | sendMessageEpic, 74 | disconnectEpic 75 | ); 76 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ui", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "format": "prettier --write packages/**/src/**/*.{ts,tsx,js,jsx,json}", 8 | "lint": "tslint 'packages/**/src/**/*.{ts,tsx}'", 9 | "lint-fix": "tslint --fix 'packages/**/src/**/*.{ts,tsx}'", 10 | "start": "webpack-dev-server --host 0.0.0.0", 11 | "build": "webpack --config webpack.prod.js" 12 | }, 13 | "husky": { 14 | "hooks": { 15 | "pre-commit": "lint-staged" 16 | } 17 | }, 18 | "lint-staged": { 19 | "*.{json}": [ 20 | "prettier --write" 21 | ], 22 | "*.{ts,tsx}": [ 23 | "prettier --write", 24 | "tslint --fix", 25 | "git add" 26 | ] 27 | }, 28 | "dependencies": { 29 | "@material-ui/core": "^3.6.1", 30 | "@material-ui/icons": "^3.0.1", 31 | "axios": "^0.18.0", 32 | "classnames": "^2.2.6", 33 | "connected-react-router": "^6.0.0", 34 | "css-loader": "^2.1.0", 35 | "immer": "^1.8.0", 36 | "js-cookie": "^2.2.0", 37 | "path-to-regexp": "^2.4.0", 38 | "react": "^16.13.1", 39 | "react-dom": "^16.13.1", 40 | "react-redux": "^7.1.9", 41 | "react-router": "^5.2.0", 42 | "react-router-dom": "^5.2.0", 43 | "redux": "^4.0.1", 44 | "redux-observable": "^1.0.0", 45 | "rxjs": "^6.3.3", 46 | "rxjs-compat": "^6.3.3", 47 | "style-loader": "^0.23.1", 48 | "tslint-config-prettier": "^1.17.0", 49 | "tslint-config-standard": "^8.0.1", 50 | "tslint-react": "^3.6.0", 51 | "videojs": "^1.0.0", 52 | "videojs-contrib-hls": "^5.15.0" 53 | }, 54 | "devDependencies": { 55 | "@types/react": "^16.9.49", 56 | "@types/react-dom": "^16.9.8", 57 | "@types/react-redux": "^7.1.9", 58 | "@types/react-router": "^5.1.8", 59 | "@types/react-router-dom": "5.1.5", 60 | "awesome-typescript-loader": "^5.2.1", 61 | "file-loader": "^3.0.1", 62 | "history": "^4.7.2", 63 | "html-webpack-plugin": "^3.2.0", 64 | "husky": "^1.2.0", 65 | "lint-staged": "^8.1.0", 66 | "prettier": "^1.15.3", 67 | "redux-devtools-extension": "^2.13.7", 68 | "tslint": "^5.11.0", 69 | "typescript": "4.0.3", 70 | "webpack": "^4.26.1", 71 | "webpack-bundle-analyzer": "^3.0.3", 72 | "webpack-cli": "^3.1.2", 73 | "webpack-dev-server": "^3.1.10" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /frontend/src/components/Authentication/Authentication.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createStyles, 3 | Theme, 4 | WithStyles, 5 | withStyles 6 | } from '@material-ui/core/styles'; 7 | import React from 'react'; 8 | import { Route, RouteComponentProps, Switch } from 'react-router'; 9 | import DashboardContainer from '../../containers/DashboardContainer'; 10 | import HeaderContainer from '../../containers/HeaderContainer'; 11 | import StreamsContainer from '../../containers/StreamsContainer'; 12 | import WebSocket from '../Socket/Socket'; 13 | import StreamsLoader from '../Streams/StreamsLoader'; 14 | 15 | const styles = (theme: Theme) => 16 | createStyles({ 17 | content: { 18 | paddingTop: 60 19 | } 20 | }); 21 | 22 | class Authentications extends React.Component { 23 | componentDidUpdate() { 24 | if (!this.props.loading && !this.props.isAuthenticated) { 25 | this.props.history.replace('/get-started'); 26 | } 27 | } 28 | 29 | componentWillMount = () => { 30 | this.init(); 31 | }; 32 | 33 | init = () => { 34 | const token = this.props.token; 35 | if (token) { 36 | this.props.authenticateUser(token); 37 | } 38 | }; 39 | 40 | render = () => { 41 | const { classes } = this.props; 42 | 43 | if (!this.props.isAuthenticated) { 44 | return null; 45 | } 46 | 47 | return ( 48 |
49 | 50 |
51 | 52 | 53 | 54 | 58 | 59 | 60 | 61 | 62 |
63 |
64 | ); 65 | }; 66 | } 67 | 68 | export default withStyles(styles)(Authentications); 69 | 70 | export interface IStateProps { 71 | isAuthenticated: boolean; 72 | token: string; 73 | loading: boolean; 74 | loaded: boolean; 75 | } 76 | 77 | export interface IDispatchProps { 78 | authenticateUser: (token: string) => void; 79 | } 80 | 81 | export type AuthenticationProps = IStateProps & 82 | IDispatchProps & 83 | RouteComponentProps<{}> & 84 | WithStyles; 85 | -------------------------------------------------------------------------------- /frontend/src/actions/socketActions.ts: -------------------------------------------------------------------------------- 1 | export enum SocketActionTypes { 2 | CONNECT = 'socket/CONNECT', 3 | CONNECTED = 'socket/CONNECTED', 4 | RECEIVE_MESSAGE = 'socket/RECEIVE_MESSAGE', 5 | SEND_MESSAGE = 'socket/SEND_MESSAGE', 6 | SENT_MESSAGE = 'socket/SENT_MESSAGE', 7 | DISCONNECT = 'socket/DISCONNECT', 8 | DISCONNECTED = 'socket/DISCONNECTED' 9 | } 10 | 11 | export function connect(url: string): IConnectAction { 12 | return { 13 | type: SocketActionTypes.CONNECT, 14 | payload: { 15 | url 16 | } 17 | }; 18 | } 19 | 20 | export function connected(): IConnectedAction { 21 | return { type: SocketActionTypes.CONNECTED }; 22 | } 23 | 24 | export function receiveMessage(msg: any): IReceiveMessageAction { 25 | if (msg.type) { 26 | return msg; 27 | } 28 | return { type: SocketActionTypes.RECEIVE_MESSAGE, payload: msg }; 29 | } 30 | 31 | export function sendMessage(msg: any): ISendMessageAction { 32 | return { type: SocketActionTypes.SEND_MESSAGE, payload: { msg } }; 33 | } 34 | 35 | export function sentMessage(): ISentMessageAction { 36 | return { type: SocketActionTypes.SENT_MESSAGE }; 37 | } 38 | 39 | export function disconnect(): IDisconnectAction { 40 | return { type: SocketActionTypes.DISCONNECT }; 41 | } 42 | 43 | export function disconnected(): IDisconnectedAction { 44 | return { type: SocketActionTypes.DISCONNECTED }; 45 | } 46 | 47 | export interface IConnectAction { 48 | type: SocketActionTypes.CONNECT; 49 | payload: { 50 | url: string; 51 | }; 52 | } 53 | 54 | export interface IConnectedAction { 55 | type: SocketActionTypes.CONNECTED; 56 | } 57 | 58 | export interface IReceiveMessageAction { 59 | type: SocketActionTypes.RECEIVE_MESSAGE; 60 | payload: { 61 | msg: any; 62 | }; 63 | } 64 | 65 | export interface ISendMessageAction { 66 | type: SocketActionTypes.SEND_MESSAGE; 67 | payload: { 68 | msg: any; 69 | }; 70 | } 71 | 72 | export interface ISentMessageAction { 73 | type: SocketActionTypes.SENT_MESSAGE; 74 | } 75 | 76 | export interface IDisconnectAction { 77 | type: SocketActionTypes.DISCONNECT; 78 | } 79 | 80 | export interface IDisconnectedAction { 81 | type: SocketActionTypes.DISCONNECTED; 82 | } 83 | 84 | export type SocketAction = 85 | | IConnectAction 86 | | IConnectedAction 87 | | IReceiveMessageAction 88 | | ISendMessageAction 89 | | IDisconnectAction 90 | | IDisconnectedAction; 91 | -------------------------------------------------------------------------------- /frontend/src/epics/authEpics.ts: -------------------------------------------------------------------------------- 1 | import { Action } from 'redux'; 2 | import { combineEpics, Epic } from 'redux-observable'; 3 | import { from, of } from 'rxjs'; 4 | import axios from '../axios'; 5 | 6 | import { push } from 'connected-react-router'; 7 | import { mapTo } from 'rxjs-compat/operator/mapTo'; 8 | import { 9 | authenticatedUser, 10 | authenticatingUser, 11 | AuthenticationActionTypes, 12 | IAuthenticatedUserAction, 13 | IAuthenticateUserAction, 14 | ILoginAction, 15 | IRegisterAction 16 | } from '../actions/authenticationActions'; 17 | 18 | export const registerEpic: Epic = action$ => 19 | action$ 20 | .ofType(AuthenticationActionTypes.REGISTER) 21 | .mergeMap((action: IRegisterAction) => 22 | from(axios.post('/users/register', action.payload)) 23 | .mergeMap(response => [ 24 | authenticatedUser( 25 | response.data.data.token, 26 | response.data.data.user, 27 | true 28 | ) 29 | ]) 30 | .startWith(authenticatingUser()) 31 | ); 32 | 33 | export const loginEpic: Epic = action$ => 34 | action$ 35 | .ofType(AuthenticationActionTypes.LOGIN) 36 | .mergeMap((action: ILoginAction) => 37 | from(axios.post('/users/login', action.payload)) 38 | .mergeMap(response => [ 39 | authenticatedUser( 40 | response.data.data.token, 41 | response.data.data.user, 42 | true 43 | ) 44 | ]) 45 | .startWith(authenticatingUser()) 46 | ); 47 | 48 | export const authenticatedUserEpic: Epic = action$ => 49 | action$ 50 | .ofType(AuthenticationActionTypes.AUTHENTICATED_TOKEN) 51 | .mergeMap((action: IAuthenticatedUserAction) => { 52 | if (!action.payload.shouldRedirect) { 53 | return of({ type: 'noop' }); 54 | } 55 | return of(push('/streams')); 56 | }); 57 | 58 | export const authenticateUserEpic: Epic = action$ => 59 | action$ 60 | .ofType(AuthenticationActionTypes.AUTHENTICATE_TOKEN) 61 | .mergeMap((action: IAuthenticateUserAction) => 62 | from(axios.get(`/users/tokeninfo?token=${action.payload.token}`)) 63 | .map(response => 64 | authenticatedUser(action.payload.token, response.data.data, false) 65 | ) 66 | .startWith(authenticatingUser()) 67 | ); 68 | 69 | export default combineEpics( 70 | registerEpic, 71 | loginEpic, 72 | authenticatedUserEpic, 73 | authenticateUserEpic 74 | ); 75 | -------------------------------------------------------------------------------- /frontend/src/components/Snackbar/Snackbar.tsx: -------------------------------------------------------------------------------- 1 | import Button from '@material-ui/core/Button'; 2 | import IconButton from '@material-ui/core/IconButton'; 3 | import Snackbar from '@material-ui/core/Snackbar'; 4 | import SnackbarContent from '@material-ui/core/SnackbarContent'; 5 | import { 6 | createStyles, 7 | Theme, 8 | withStyles, 9 | WithStyles 10 | } from '@material-ui/core/styles'; 11 | import CloseIcon from '@material-ui/icons/Close'; 12 | import classNames from 'classnames'; 13 | import React from 'react'; 14 | import { IToast } from '../../reducers/snackbarReducer'; 15 | 16 | const styles = (theme: Theme) => 17 | createStyles({ 18 | closeButton: { 19 | padding: theme.spacing.unit / 2 20 | }, 21 | error: { 22 | background: 'red' 23 | } 24 | }); 25 | 26 | class Toast extends React.Component { 27 | hideSnackbar = (id: number) => e => { 28 | this.props.hideSnackbar(id); 29 | }; 30 | 31 | getActions = (toast: IToast) => { 32 | const { classes } = this.props; 33 | 34 | return ( 35 |
36 | 41 | 42 | 43 |
44 | ); 45 | }; 46 | 47 | render() { 48 | const { classes } = this.props; 49 | 50 | return ( 51 |
52 | {this.props.toasts.map(toast => ( 53 | 67 | {toast.message}
} 70 | action={this.getActions(toast)} 71 | /> 72 | 73 | ))} 74 |
75 | ); 76 | } 77 | } 78 | 79 | export interface IToastStateProps { 80 | toasts: IToast[]; 81 | } 82 | 83 | export interface IToastDispatchProps { 84 | hideSnackbar: (id: number) => void; 85 | } 86 | 87 | type ToastProps = IToastStateProps & 88 | IToastDispatchProps & 89 | WithStyles; 90 | 91 | export default withStyles(styles)(Toast); 92 | -------------------------------------------------------------------------------- /frontend/src/epics/snackbarEpics.ts: -------------------------------------------------------------------------------- 1 | import { ActionsObservable, combineEpics, Epic } from 'redux-observable'; 2 | import { from, of } from 'rxjs'; 3 | import { 4 | ChannelAction, 5 | ChannelActionTypes, 6 | IDeleteChannelFailureAction, 7 | IDeleteChannelSuccessAction 8 | } from '../actions/channelActions'; 9 | import { showSnackbar } from '../actions/snackbarActions'; 10 | import { 11 | ICreateStreamFailureAction, 12 | ICreateStreamSuccessAction, 13 | IDeleteStreamFailureAction, 14 | IDeleteStreamSuccessAction, 15 | StreamAction, 16 | StreamActionTypes 17 | } from '../actions/streamActions'; 18 | 19 | export const createStreamSuccessEpic = ( 20 | action$: ActionsObservable 21 | ) => 22 | action$ 23 | .ofType(StreamActionTypes.CREATE_SUCCESS_SUCCESS) 24 | .map((action: ICreateStreamSuccessAction) => 25 | showSnackbar('Stream created successfully', false) 26 | ); 27 | 28 | export const createStreamFailureEpic = ( 29 | action$: ActionsObservable 30 | ) => 31 | action$ 32 | .ofType(StreamActionTypes.CREATE_STREAM_FAILURE) 33 | .map((action: ICreateStreamFailureAction) => 34 | showSnackbar('Unable to create stream', true) 35 | ); 36 | 37 | export const deleteStreamSuccessEpic = ( 38 | action$: ActionsObservable 39 | ) => 40 | action$ 41 | .ofType(StreamActionTypes.DELETE_STREAM_SUCCESS) 42 | .map((action: IDeleteStreamSuccessAction) => 43 | showSnackbar('Stream deleted successfully', false) 44 | ); 45 | 46 | export const deleteStreamFailureEpic = ( 47 | action$: ActionsObservable 48 | ) => 49 | action$ 50 | .ofType(StreamActionTypes.DELETE_STREAM_FAILURE) 51 | .map((action: IDeleteStreamFailureAction) => 52 | showSnackbar('Unable to delete stream', true) 53 | ); 54 | 55 | export const deleteChannelSuccessEpic = ( 56 | action$: ActionsObservable 57 | ) => 58 | action$ 59 | .ofType(ChannelActionTypes.DELETE_CHANNEL_SUCCESS) 60 | .map((action: IDeleteChannelSuccessAction) => 61 | showSnackbar('Deleted channel successfully', false) 62 | ); 63 | 64 | export const deleteChannelFailureEpic = ( 65 | action$: ActionsObservable 66 | ) => 67 | action$ 68 | .ofType(ChannelActionTypes.DELETE_CHANNEL_FAILURE) 69 | .map((action: IDeleteChannelFailureAction) => 70 | showSnackbar('Unable to delete channel', true) 71 | ); 72 | 73 | export default combineEpics( 74 | createStreamSuccessEpic, 75 | createStreamFailureEpic, 76 | deleteStreamSuccessEpic, 77 | deleteStreamFailureEpic, 78 | deleteChannelSuccessEpic, 79 | deleteChannelFailureEpic 80 | ); 81 | -------------------------------------------------------------------------------- /frontend/src/components/LoginForm/LoginForm.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | // Material-UI Components 4 | import Button from '@material-ui/core/Button'; 5 | import Checkbox from '@material-ui/core/Checkbox'; 6 | import FormControlLabel from '@material-ui/core/FormControlLabel'; 7 | import Paper from '@material-ui/core/Paper'; 8 | import { 9 | createStyles, 10 | Theme, 11 | withStyles, 12 | WithStyles 13 | } from '@material-ui/core/styles'; 14 | import TextField from '@material-ui/core/TextField'; 15 | import Typography from '@material-ui/core/Typography'; 16 | import { Link } from 'react-router-dom'; 17 | 18 | const styles = (theme: Theme) => 19 | createStyles({ 20 | row: { 21 | marginTop: 25, 22 | display: 'flex', 23 | justifyContent: 'space-between' 24 | } 25 | }); 26 | 27 | class LoginForm extends React.Component { 28 | state = { 29 | email: '', 30 | password: '', 31 | rememberMe: false 32 | }; 33 | 34 | changeEmail = (e: React.ChangeEvent) => { 35 | this.setState({ email: e.target.value }); 36 | }; 37 | 38 | changePassword = (e: React.ChangeEvent) => { 39 | this.setState({ password: e.target.value }); 40 | }; 41 | 42 | changeRememberMe = (e: React.ChangeEvent) => { 43 | this.setState({ rememberMe: e.target.checked }); 44 | }; 45 | 46 | login = () => { 47 | const { email, password, rememberMe } = this.state; 48 | this.props.login(email, password, rememberMe); 49 | }; 50 | 51 | render() { 52 | const { classes } = this.props; 53 | const { email, password, rememberMe } = this.state; 54 | 55 | return ( 56 | <> 57 |
58 | 64 |
65 | 66 |
67 | 74 |
75 | 76 |
77 |
78 | 81 |
82 |
83 | 84 | ); 85 | } 86 | } 87 | 88 | export default withStyles(styles)(LoginForm); 89 | 90 | export interface IDispatchProps { 91 | login: (email: string, password: string, shouldRemember: boolean) => void; 92 | } 93 | 94 | export type LoginFormProps = IDispatchProps & WithStyles; 95 | -------------------------------------------------------------------------------- /frontend/src/reducers/streamReducer.ts: -------------------------------------------------------------------------------- 1 | import { produce } from 'immer'; 2 | import { StreamAction, StreamActionTypes } from '../actions/streamActions'; 3 | import { ApiStatus, IStream } from '../models'; 4 | 5 | export const initialStreamState: IStreamState = { 6 | byId: {}, 7 | ids: [], 8 | loadingStatus: ApiStatus.IN_PROGRESS, 9 | open: false, 10 | addingStatus: ApiStatus.SUCCESS, 11 | streamId: -1 12 | }; 13 | 14 | export default function streamReducer( 15 | state = initialStreamState, 16 | action: StreamAction 17 | ) { 18 | return produce(state, draft => { 19 | switch (action.type) { 20 | case StreamActionTypes.LOAD_STREAMS: 21 | draft.loadingStatus = ApiStatus.IN_PROGRESS; 22 | draft.byId = {}; 23 | draft.ids = []; 24 | break; 25 | 26 | case StreamActionTypes.LOAD_STREAMS_FAILURE: 27 | draft.loadingStatus = ApiStatus.FAILURE; 28 | break; 29 | 30 | case StreamActionTypes.LOAD_STREAMS_SUCCESS: 31 | draft.loadingStatus = ApiStatus.SUCCESS; 32 | action.payload.streams.forEach(stream => { 33 | draft.byId[stream.id] = stream; 34 | draft.ids.push(stream.id); 35 | }); 36 | break; 37 | 38 | case StreamActionTypes.OPEN_NEW_STREAM_DIALOG: 39 | draft.open = true; 40 | break; 41 | 42 | case StreamActionTypes.CLOSE_NEW_STREAM_DIALOG: 43 | draft.open = false; 44 | break; 45 | 46 | case StreamActionTypes.CREATE_STREAM: 47 | draft.addingStatus = ApiStatus.IN_PROGRESS; 48 | break; 49 | 50 | case StreamActionTypes.CREATE_STREAM_FAILURE: 51 | draft.addingStatus = ApiStatus.FAILURE; 52 | break; 53 | 54 | case StreamActionTypes.CREATE_SUCCESS_SUCCESS: 55 | draft.addingStatus = ApiStatus.SUCCESS; 56 | draft.open = false; 57 | break; 58 | 59 | case StreamActionTypes.SELECT_STREAM: 60 | draft.streamId = action.payload.streamId; 61 | break; 62 | 63 | case StreamActionTypes.DELETE_STREAM: 64 | draft.byId[action.payload.streamId].deleting = ApiStatus.IN_PROGRESS; 65 | break; 66 | 67 | case StreamActionTypes.DELETE_STREAM_FAILURE: 68 | draft.byId[action.payload.streamId].deleting = ApiStatus.SUCCESS; 69 | break; 70 | 71 | case StreamActionTypes.DELETE_STREAM_SUCCESS: 72 | draft.ids.splice(draft.ids.indexOf(action.payload.streamId), 1); 73 | delete draft.byId[action.payload.streamId]; 74 | break; 75 | } 76 | }); 77 | } 78 | 79 | export interface IStreamState { 80 | byId: { [key: number]: IStream }; 81 | ids: number[]; 82 | loadingStatus: ApiStatus; 83 | open: boolean; 84 | addingStatus: ApiStatus; 85 | streamId: number; 86 | } 87 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | redis: 5 | container_name: redis 6 | image: redis:alpine 7 | 8 | db: 9 | container_name: db 10 | restart: unless-stopped 11 | image: mysql:latest 12 | command: --default-authentication-plugin=mysql_native_password 13 | environment: 14 | MYSQL_ROOT_PASSWORD: root 15 | MYSQL_USER: streamer 16 | MYSQL_PASSWORD: password 17 | MYSQL_DATABASE: gotest 18 | ports: 19 | - 3306:3306 20 | expose: 21 | - 3306 22 | volumes: 23 | - storage:/var/lib/mysql 24 | 25 | rtmp: 26 | container_name: rtmp 27 | links: 28 | - web 29 | build: 30 | context: . 31 | dockerfile: ./scripts/rtmp/Dockerfile 32 | volumes: 33 | - public:/hls-preview/ 34 | entrypoint: ./scripts/wait-for-it.sh web:80 -- air -c ./scripts/rtmp/.air.toml 35 | restart: unless-stopped 36 | ports: 37 | - "1935:1935" 38 | expose: 39 | - 1935 40 | environment: 41 | GRPC_HOST: web 42 | REDIS_HOST: redis 43 | 44 | web: 45 | container_name: web 46 | restart: always 47 | links: 48 | - db 49 | - redis 50 | build: 51 | context: . 52 | dockerfile: ./scripts/web/Dockerfile 53 | entrypoint: ./scripts/wait-for-it.sh db:3306 -- air -c ./scripts/web/.air.toml 54 | volumes: 55 | - ./:/go/src/github.com/praveen001/go-rtmp-web-server 56 | restart: unless-stopped 57 | expose: 58 | - 4005 59 | - 5000 60 | environment: 61 | MYSQL_HOST: db 62 | MYSQL_USER: streamer 63 | MYSQL_PASSWORD: password 64 | MYSQL_DATABASE: gotest 65 | REDIS_HOST: redis 66 | env_file: 67 | - ./scripts/configs.env 68 | 69 | ui: 70 | container_name: ui 71 | build: 72 | context: . 73 | dockerfile: ./scripts/ui/Dockerfile 74 | entrypoint: yarn start 75 | volumes: 76 | - ./frontend/src/:/ui/src 77 | restart: unless-stopped 78 | expose: 79 | - 8080 80 | 81 | nginx: 82 | container_name: nginx 83 | links: 84 | - web 85 | - ui 86 | build: 87 | context: . 88 | dockerfile: ./scripts/nginx/Dockerfile 89 | volumes: 90 | - public:/usr/share/nginx/html/hls-preview/ 91 | ports: 92 | - "80:80" 93 | expose: 94 | - 80 95 | 96 | volumes: 97 | public: 98 | storage: 99 | -------------------------------------------------------------------------------- /frontend/src/components/RegisterForm/RegisterForm.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | // Material-UI Components 4 | import Button from '@material-ui/core/Button'; 5 | import Paper from '@material-ui/core/Paper'; 6 | import { 7 | createStyles, 8 | Theme, 9 | withStyles, 10 | WithStyles 11 | } from '@material-ui/core/styles'; 12 | import TextField from '@material-ui/core/TextField'; 13 | 14 | const styles = (theme: Theme) => 15 | createStyles({ 16 | row: { 17 | marginTop: 25, 18 | display: 'flex', 19 | justifyContent: 'space-between' 20 | } 21 | }); 22 | 23 | class RegisterForm extends React.Component { 24 | state = { 25 | name: '', 26 | email: '', 27 | password: '', 28 | rememberMe: false 29 | }; 30 | 31 | changeName = (e: React.ChangeEvent) => { 32 | this.setState({ name: e.target.value }); 33 | }; 34 | 35 | changeEmail = (e: React.ChangeEvent) => { 36 | this.setState({ email: e.target.value }); 37 | }; 38 | 39 | changePassword = (e: React.ChangeEvent) => { 40 | this.setState({ password: e.target.value }); 41 | }; 42 | 43 | register = () => { 44 | const { name, email, password } = this.state; 45 | this.props.register(name, email, password); 46 | }; 47 | 48 | render() { 49 | const { classes } = this.props; 50 | const { name, email, password } = this.state; 51 | 52 | return ( 53 | <> 54 |
55 | 61 |
62 | 63 |
64 | 70 |
71 | 72 |
73 | 80 |
81 | 82 |
83 |
84 | 87 |
88 |
89 | 90 | ); 91 | } 92 | } 93 | 94 | export default withStyles(styles)(RegisterForm); 95 | 96 | export interface IDispatchProps { 97 | register: (name: string, email: string, password: string) => void; 98 | } 99 | 100 | export type RegisterFormProps = IDispatchProps & WithStyles; 101 | -------------------------------------------------------------------------------- /frontend/src/actions/authenticationActions.ts: -------------------------------------------------------------------------------- 1 | export enum AuthenticationActionTypes { 2 | REGISTER = 'auth/register', 3 | LOGIN = 'auth/login', 4 | AUTHENTICATE_TOKEN = 'auth/authenticateUser', 5 | AUTHENTICATING_TOKEN = 'auth/authenticatingUser', 6 | AUTHENTICATED_TOKEN = 'auth/authenticatedUser', 7 | LOGOUT = 'auth/logout' 8 | } 9 | 10 | export function register( 11 | name: string, 12 | email: string, 13 | password: string 14 | ): IRegisterAction { 15 | return { 16 | type: AuthenticationActionTypes.REGISTER, 17 | payload: { 18 | name, 19 | email, 20 | password 21 | } 22 | }; 23 | } 24 | 25 | export function login( 26 | email: string, 27 | password: string, 28 | rememberMe: boolean 29 | ): ILoginAction { 30 | return { 31 | type: AuthenticationActionTypes.LOGIN, 32 | payload: { email, password, rememberMe } 33 | }; 34 | } 35 | 36 | export function authenticateUser(token: string): IAuthenticateUserAction { 37 | return { 38 | type: AuthenticationActionTypes.AUTHENTICATE_TOKEN, 39 | payload: { 40 | token 41 | } 42 | }; 43 | } 44 | 45 | export function authenticatingUser(): IAuthenticatingUserAction { 46 | return { 47 | type: AuthenticationActionTypes.AUTHENTICATING_TOKEN 48 | }; 49 | } 50 | 51 | export function authenticatedUser( 52 | token: string, 53 | user: any, 54 | shouldRedirect: boolean 55 | ): IAuthenticatedUserAction { 56 | return { 57 | type: AuthenticationActionTypes.AUTHENTICATED_TOKEN, 58 | payload: { token, user, shouldRedirect } 59 | }; 60 | } 61 | 62 | export function logout(): ILogoutAction { 63 | return { type: AuthenticationActionTypes.LOGOUT }; 64 | } 65 | 66 | export interface IRegisterAction { 67 | type: AuthenticationActionTypes.REGISTER; 68 | payload: { 69 | name: string; 70 | email: string; 71 | password: string; 72 | }; 73 | } 74 | 75 | export interface ILoginAction { 76 | type: AuthenticationActionTypes.LOGIN; 77 | payload: { 78 | email: string; 79 | password: string; 80 | rememberMe: boolean; 81 | }; 82 | } 83 | 84 | export interface IAuthenticateUserAction { 85 | type: AuthenticationActionTypes.AUTHENTICATE_TOKEN; 86 | payload: { 87 | token: string; 88 | }; 89 | } 90 | 91 | export interface IAuthenticatingUserAction { 92 | type: AuthenticationActionTypes.AUTHENTICATING_TOKEN; 93 | } 94 | 95 | export interface IAuthenticatedUserAction { 96 | type: AuthenticationActionTypes.AUTHENTICATED_TOKEN; 97 | payload: { 98 | token: string; 99 | user: any; 100 | shouldRedirect: boolean; 101 | }; 102 | } 103 | 104 | export interface ILogoutAction { 105 | type: AuthenticationActionTypes.LOGOUT; 106 | } 107 | 108 | export type AuthenticationAction = 109 | | IRegisterAction 110 | | ILoginAction 111 | | IAuthenticateUserAction 112 | | IAuthenticatingUserAction 113 | | IAuthenticatedUserAction 114 | | ILogoutAction; 115 | -------------------------------------------------------------------------------- /frontend/src/components/Icons/Periscope.tsx: -------------------------------------------------------------------------------- 1 | import SvgIcon from '@material-ui/core/SvgIcon'; 2 | import React from 'react'; 3 | /* tslint:disable */ 4 | export default props => ( 5 | 6 | 11 | 12 | ); 13 | -------------------------------------------------------------------------------- /frontend/src/components/Streams/NewStream.tsx: -------------------------------------------------------------------------------- 1 | import Button from '@material-ui/core/Button'; 2 | import CircularProgress from '@material-ui/core/CircularProgress'; 3 | import Dialog from '@material-ui/core/Dialog'; 4 | import IconButton from '@material-ui/core/IconButton'; 5 | import { 6 | createStyles, 7 | Theme, 8 | withStyles, 9 | WithStyles 10 | } from '@material-ui/core/styles'; 11 | import TextField from '@material-ui/core/TextField'; 12 | import Typography from '@material-ui/core/Typography'; 13 | import CloseIcon from '@material-ui/icons/Close'; 14 | import React from 'react'; 15 | import { ApiStatus } from '../../models'; 16 | import Title from '../Title/Title'; 17 | 18 | const styles = (theme: Theme) => 19 | createStyles({ 20 | content: { 21 | padding: theme.spacing.unit, 22 | display: 'flex', 23 | flexDirection: 'column' 24 | }, 25 | row: { 26 | marginBottom: 25 27 | } 28 | }); 29 | 30 | class NewStream extends React.Component { 31 | state = { 32 | name: '' 33 | }; 34 | 35 | onChange = e => { 36 | this.setState({ 37 | name: e.target.value 38 | }); 39 | }; 40 | 41 | createStream = () => { 42 | this.props.createStream(this.state.name); 43 | }; 44 | 45 | getContent = () => { 46 | const { loadingStatus, closeNewStreamDialog, classes } = this.props; 47 | 48 | if (loadingStatus === ApiStatus.IN_PROGRESS) { 49 | return ; 50 | } 51 | 52 | return ( 53 | <> 54 | 55 | <Typography variant="button">Create Stream</Typography> 56 | <IconButton onClick={closeNewStreamDialog}> 57 | <CloseIcon /> 58 | </IconButton> 59 | 60 |
61 | 67 |
68 | 71 | 72 | ); 73 | }; 74 | 75 | render() { 76 | const { classes, open, closeNewStreamDialog } = this.props; 77 | 78 | return ( 79 | 85 |
{this.getContent()}
86 |
87 | ); 88 | } 89 | } 90 | 91 | export default withStyles(styles)(NewStream); 92 | 93 | export interface INewStreamStateProps { 94 | open: boolean; 95 | loadingStatus: ApiStatus; 96 | } 97 | 98 | export interface INewStreamDispatchProps { 99 | closeNewStreamDialog: () => {}; 100 | createStream: (name: string) => {}; 101 | } 102 | 103 | type NewStreamProps = INewStreamStateProps & 104 | INewStreamDispatchProps & 105 | WithStyles; 106 | -------------------------------------------------------------------------------- /controllers/channels.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "strconv" 7 | 8 | "github.com/praveen001/go-rtmp-web-server/models" 9 | ) 10 | 11 | // AddChannel a new channel for a given user 12 | func (c *ApplicationContext) AddChannel(w http.ResponseWriter, r *http.Request) { 13 | channel := &models.Channel{} 14 | if err := json.NewDecoder(r.Body).Decode(channel); err != nil { 15 | c.NewResponse(w).Status(400).SendJSON(Response{ 16 | Error: true, 17 | Message: "Unable to parse request", 18 | }) 19 | return 20 | } 21 | channel.Enabled = true 22 | 23 | stream := &models.Stream{ 24 | Model: models.Model{ 25 | ID: channel.StreamID, 26 | }, 27 | } 28 | c.DB.Find(&stream, stream) 29 | if stream.UserID != int(r.Context().Value("id").(float64)) { 30 | c.NewResponse(w).Status(403).SendJSON(Response{ 31 | Error: true, 32 | Message: "Unauthorized", 33 | }) 34 | return 35 | } 36 | 37 | c.DB.Save(channel) 38 | 39 | c.NewResponse(w).Status(200).SendJSON(Response{ 40 | Error: false, 41 | Message: "Channel added successfully", 42 | }) 43 | } 44 | 45 | // ListChannels all channels associated to a stream 46 | func (c *ApplicationContext) ListChannels(w http.ResponseWriter, r *http.Request) { 47 | streamID, err := strconv.Atoi(r.FormValue("streamId")) 48 | if err != nil { 49 | c.NewResponse(w).Status(400).SendJSON(Response{ 50 | Error: true, 51 | Message: "Invalid stream ID", 52 | }) 53 | return 54 | } 55 | 56 | var channels []models.Channel 57 | c.DB.Find(&channels, models.Channel{StreamID: streamID}) 58 | 59 | c.NewResponse(w).Status(http.StatusOK).SendJSON(Response{ 60 | Data: channels, 61 | }) 62 | } 63 | 64 | // DeleteChannel will delete a channel associated to a stream 65 | func (c *ApplicationContext) DeleteChannel(w http.ResponseWriter, r *http.Request) { 66 | streamID, err := strconv.Atoi(r.FormValue("streamId")) 67 | if err != nil { 68 | c.NewResponse(w).Status(400).SendJSON(Response{ 69 | Error: true, 70 | Message: "Invalid stream ID", 71 | }) 72 | return 73 | } 74 | channelID, err := strconv.Atoi(r.FormValue("channelId")) 75 | if err != nil { 76 | c.NewResponse(w).Status(400).SendJSON(Response{ 77 | Error: true, 78 | Message: "Invalid stream ID", 79 | }) 80 | return 81 | } 82 | 83 | // Access check 84 | stream := &models.Stream{ 85 | Model: models.Model{ 86 | ID: streamID, 87 | }, 88 | } 89 | c.DB.Find(&stream, stream) 90 | if stream.UserID != int(r.Context().Value("id").(float64)) { 91 | c.NewResponse(w).Status(403).SendJSON(Response{ 92 | Error: true, 93 | Message: "Unauthorized", 94 | }) 95 | return 96 | } 97 | 98 | channel := &models.Channel{ 99 | StreamID: streamID, 100 | Model: models.Model{ 101 | ID: channelID, 102 | }, 103 | } 104 | 105 | c.DB.Delete(channel) 106 | 107 | c.NewResponse(w).Status(200).SendJSON(Response{ 108 | Message: "Channel has been deleted successfully", 109 | }) 110 | } 111 | -------------------------------------------------------------------------------- /frontend/src/components/Header/Header.tsx: -------------------------------------------------------------------------------- 1 | import AppBar from '@material-ui/core/AppBar'; 2 | import Button from '@material-ui/core/Button'; 3 | import FormControlLabel from '@material-ui/core/FormControlLabel'; 4 | import Menu from '@material-ui/core/Menu'; 5 | import MenuItem from '@material-ui/core/MenuItem'; 6 | import { 7 | createStyles, 8 | Theme, 9 | withStyles, 10 | WithStyles 11 | } from '@material-ui/core/styles'; 12 | import Switch from '@material-ui/core/Switch'; 13 | import Toolbar from '@material-ui/core/Toolbar'; 14 | import Typography from '@material-ui/core/Typography'; 15 | import React from 'react'; 16 | 17 | const styles = (theme: Theme) => 18 | createStyles({ 19 | appBar: { 20 | color: '#fff' 21 | }, 22 | toolbar: { 23 | display: 'flex', 24 | justifyContent: 'space-between' 25 | }, 26 | logo: { 27 | textTransform: 'none' 28 | } 29 | }); 30 | 31 | class Header extends React.Component { 32 | anchorEl; 33 | 34 | openMenu = e => { 35 | this.anchorEl = e.target; 36 | this.props.openMenu(); 37 | }; 38 | 39 | closeMenu = () => { 40 | this.anchorEl = undefined; 41 | this.props.closeMenu(); 42 | }; 43 | 44 | render() { 45 | const { classes, theme, toggleTheme, logout } = this.props; 46 | 47 | const darkTheme = theme === 'dark'; 48 | 49 | return ( 50 | 51 | 52 | 57 |
58 | 66 | 72 | 73 | } 76 | label="Dark Theme" 77 | /> 78 | 79 | Logout 80 | 81 |
82 |
83 |
84 | ); 85 | } 86 | } 87 | 88 | export default withStyles(styles)(Header); 89 | 90 | export interface IHeaderStateProps { 91 | open: boolean; 92 | user: any; 93 | theme: string; 94 | } 95 | 96 | export interface IHeaderDispatchProps { 97 | openMenu: () => {}; 98 | closeMenu: () => {}; 99 | toggleTheme: () => {}; 100 | logout: () => {}; 101 | } 102 | 103 | type HeaderProps = IHeaderStateProps & 104 | IHeaderDispatchProps & 105 | WithStyles; 106 | -------------------------------------------------------------------------------- /frontend/src/components/Icons/Youtube.tsx: -------------------------------------------------------------------------------- 1 | import SvgIcon from '@material-ui/core/SvgIcon'; 2 | import React from 'react'; 3 | /* tslint:disable */ 4 | export default props => ( 5 | 6 | 11 | 12 | ); 13 | -------------------------------------------------------------------------------- /rtmp/connection.go: -------------------------------------------------------------------------------- 1 | package rtmp 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "net" 7 | ) 8 | 9 | const ( 10 | _ = iota 11 | handshakeStage 12 | commandStage 13 | commandStageDone 14 | ) 15 | 16 | const ( 17 | genuineFMS = "Genuine Adobe Flash Media Server 001" 18 | genuineFp = "Genuine Adobe Flash Player 001" 19 | ) 20 | 21 | // Parse States 22 | const ( 23 | RtmpParseInit = iota 24 | RtmpParseBasicHeader 25 | RtmpParseMessageHeader 26 | RtmpParseExtendedHeader 27 | RtmpParsePayload 28 | ) 29 | 30 | type Channel struct { 31 | ChannelID int64 32 | Send chan []byte 33 | Exit chan bool 34 | } 35 | 36 | // Connection a RTMP connection 37 | type Connection struct { 38 | Conn net.Conn 39 | Reader *bufio.Reader 40 | Writer *bufio.Writer 41 | ReadBuffer []byte 42 | WriteBuffer []byte 43 | Stage int 44 | HandshakeDone bool 45 | ConnectionDone bool 46 | GotMessage bool 47 | csMap map[uint32]*rtmpChunk 48 | ReadMaxChunkSize int 49 | WriteMaxChunkSize int 50 | AppName string 51 | StreamKey string 52 | Streams int 53 | 54 | AudioSampleRate float64 55 | AudioChannels float64 56 | VideoWidth float64 57 | VideoHeight float64 58 | FPS float64 59 | 60 | Clients []Channel 61 | WaitingClients []Channel 62 | StreamID int64 63 | UserID int64 64 | Context *StreamerContext 65 | GotFirstAudio bool 66 | GotFirstVideo bool 67 | MetaData []byte 68 | FirstAudio []byte 69 | FirstVideo []byte 70 | GOP [][]byte 71 | 72 | Proxy []*bufio.Writer 73 | } 74 | 75 | // Serve the RTMP connection 76 | func (c *Connection) Serve() { 77 | 78 | if err := c.handshake(); err != nil { 79 | fmt.Println("Handshake failed") 80 | return 81 | } 82 | fmt.Println("Handhshake completed") 83 | 84 | if err := c.prepare(); err != nil { 85 | fmt.Println("Error while preparing RTMP connection") 86 | return 87 | } 88 | fmt.Println("Connection completed") 89 | 90 | for c.Stage < commandStageDone { 91 | c.readMessage() 92 | } 93 | fmt.Println("Command stage completed") 94 | 95 | for { 96 | if err := c.readChunk(); err != nil { 97 | c.closeConnection() 98 | return 99 | } 100 | } 101 | 102 | // var zz bool 103 | // var cc int 104 | // p := make([]byte, 5000) 105 | // for { 106 | // n, err := c.Reader.Read(p) 107 | // if err != nil { 108 | // if err == io.EOF { 109 | // c.closeConnection() 110 | // break 111 | // } 112 | // } 113 | 114 | // m := make([]byte, n) 115 | // copy(m, p) 116 | // for _, client := range c.Clients { 117 | // client.Send <- m 118 | // } 119 | // c.GOP = append(c.GOP, m) 120 | // if !zz && cc > 200 { 121 | // zz = true 122 | // c.Context.preview <- "rtmp://localhost/" + c.AppName + "/" + c.StreamKey 123 | // } else { 124 | // cc++ 125 | // } 126 | // } 127 | } 128 | 129 | /* 130 | ffmpeg -v verbose -i rtmp://localhost:1935/live2/123 -c:v libx264 -c:a aac -ac 1 -strict -2 -crf 18 -profile:v baseline -maxrate 400k -bufsize 1835k -pix_fmt yuv420p -flags -global_header -hls_time 10 -hls_list_size 6 -hls_wrap 10 -start_number 1 /home/praveen/hls/123.m3u8 131 | */ 132 | -------------------------------------------------------------------------------- /frontend/src/components/AddChannelForm/AddChannelForm.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | // Material-UI Components 4 | import Button from '@material-ui/core/Button'; 5 | import { 6 | createStyles, 7 | Theme, 8 | withStyles, 9 | WithStyles 10 | } from '@material-ui/core/styles'; 11 | import TextField from '@material-ui/core/TextField'; 12 | import Typography from '@material-ui/core/Typography'; 13 | 14 | const styles = (theme: Theme) => 15 | createStyles({ 16 | addChannelForm: { 17 | display: 'flex', 18 | flexDirection: 'column', 19 | width: 450, 20 | padding: theme.spacing.unit * 1, 21 | paddingRight: 35, 22 | marginRight: 20, 23 | borderRight: `1px solid ${theme.palette.divider}` 24 | }, 25 | row: { 26 | marginTop: 25, 27 | display: 'flex', 28 | justifyContent: 'space-between' 29 | } 30 | }); 31 | 32 | class AddChannelForm extends React.Component { 33 | state = { 34 | name: '', 35 | url: '', 36 | key: '' 37 | }; 38 | 39 | changeName = (e: React.ChangeEvent) => { 40 | this.setState({ name: e.target.value }); 41 | }; 42 | 43 | changeURL = (e: React.ChangeEvent) => { 44 | this.setState({ url: e.target.value }); 45 | }; 46 | 47 | changeKey = (e: React.ChangeEvent) => { 48 | this.setState({ key: e.target.value }); 49 | }; 50 | 51 | addChannel = () => { 52 | const { name, url, key } = this.state; 53 | this.props.addChannel(this.props.streamId, name, url, key); 54 | }; 55 | 56 | render() { 57 | const { classes } = this.props; 58 | const { name, url, key } = this.state; 59 | 60 | return ( 61 |
62 | Add Custom RTMP Endpoint 63 |
64 | 70 |
71 | 72 |
73 | 79 |
80 | 81 |
82 | 89 |
90 | 91 |
92 |
93 | 100 |
101 |
102 |
103 | ); 104 | } 105 | } 106 | 107 | export default withStyles(styles)(AddChannelForm); 108 | 109 | export interface IOwnProps { 110 | streamId: number; 111 | } 112 | 113 | export interface IDispatchProps { 114 | addChannel: ( 115 | streamId: number, 116 | name: string, 117 | url: string, 118 | key: string 119 | ) => void; 120 | } 121 | 122 | export type AddChannelFormProps = IOwnProps & 123 | IDispatchProps & 124 | WithStyles; 125 | -------------------------------------------------------------------------------- /rtmp/preview.go: -------------------------------------------------------------------------------- 1 | package rtmp 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "os" 8 | "os/exec" 9 | ) 10 | 11 | // Path where hls playlist will get saved 12 | var ( 13 | HLSOutputBasePath = "/hls-preview/" 14 | ) 15 | 16 | // InitPreviewServer .. 17 | func InitPreviewServer(ctx *StreamerContext) { 18 | for { 19 | select { 20 | case streamKey := <-ctx.preview: 21 | if len(HLSOutputBasePath) != 0 { 22 | go makeHls(ctx, streamKey) 23 | } 24 | c := ctx.get(streamKey) 25 | redisMsg := map[string]interface{}{ 26 | "ID": c.UserID, 27 | "Message": map[string]interface{}{ 28 | "type": "stats/previewReady", 29 | "streamId": c.StreamID, 30 | }, 31 | } 32 | redisMsgBytes, _ := json.Marshal(redisMsg) 33 | 34 | ctx.Redis.Do("PUBLISH", "Stream", redisMsgBytes) 35 | } 36 | } 37 | } 38 | 39 | func makeHls(ctx *StreamerContext, streamKey string) { 40 | c, ok := ctx.sessions[streamKey] 41 | if !ok { 42 | fmt.Println("Not Found", streamKey) 43 | } 44 | output := HLSOutputBasePath + streamKey 45 | if _, err := os.Stat(output); err != nil { 46 | if os.IsNotExist(err) { 47 | os.MkdirAll(output, os.ModePerm) 48 | } 49 | } 50 | 51 | f, err := os.Create(output + "/index.m3u8") 52 | if err != nil { 53 | panic(err) 54 | } 55 | f.Write([]byte("#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:8\n#EXT-X-MEDIA-SEQUENCE:1")) 56 | f.Close() 57 | 58 | fmt.Println("Generating preview for", streamKey, c.AppName) 59 | cmd := exec.Command("ffmpeg", "-v", "verbose", "-i", "rtmp://localhost/"+c.AppName+"/"+streamKey, "-c:v", "libx264", "-c:a", "aac", "-ac", "1", "-strict", "-2", "-crf", "18", "-profile:v", "baseline", "-maxrate", "400k", "-bufsize", "1835k", "-pix_fmt", "yuv420p", "-flags", "-global_header", "-hls_time", "3", "-hls_list_size", "6", "-hls_wrap", "10", "-start_number", "1", output+"/index.m3u8") 60 | err = cmd.Run() 61 | os.RemoveAll(output) 62 | if err != nil { 63 | log.Fatal(err) 64 | } 65 | fmt.Println("FFMPEG DONE") 66 | } 67 | 68 | //ffmpeg -v verbose -i rtmp://localhost/live2/688ecdd5-44ba-4445-95a2-624d58c420bd -c:v libx264 -c:a aac -ac 1 -strict -2 -crf 18 -profile:v baseline -maxrate 400k -bufsize 1835k -pix_fmt yuv420p -flags -global_header -hls_time 3 -hls_list_size 6 -hls_wrap 10 -start_number 1 hls/index.m3u8 69 | 70 | // ffmpeg -i demo.mp4 -i demo.mp4 -filter hstack output.mp4 71 | 72 | // Not working 73 | // ffmpeg -v verbose -i rtmp://localhost/live2/688ecdd5-44ba-4445-95a2-624d58c420bd -i rtmp://localhost/live2/688ecdd5-44ba-4445-95a2-624d58c420bd -filter hstack -c:v libx264 -c:a aac -ac 1 -strict -2 -crf 18 -profile:v baseline -maxrate 400k -bufsize 1835k -pix_fmt yuv420p -flags -global_header -hls_time 3 -hls_list_size 6 -hls_wrap 10 -start_number 1 hls/index.m3u8 74 | 75 | // ffmpeg -i rtmp://localhost/live2/688ecdd5-44ba-4445-95a2-624d58c420bd -i rtmp://localhost/live2/688ecdd5-44ba-4445-95a2-624d58c420bd -filter_complex "[0:v][1:v]hstack=inputs=2[v]" -map "[v]" output.mp4 76 | 77 | // ffmpeg -i rtmp://localhost/live2/688ecdd5-44ba-4445-95a2-624d58c420bd -i rtmp://localhost/live2/688ecdd5-44ba-4445-95a2-624d58c420bd -filter_complex "[0:v][1:v]hstack=inputs=2[v]" -map "[v]" -f flv rtmp://localhost/live2/eedd6f60-96dc-4623-9f8a-dc02e6030a09 78 | 79 | // ffmpeg -re -i demo.mp4 -vcodec libx264 -profile:v main -preset:v medium -r 30 -g 60 -keyint_min 60 -sc_threshold 0 -b:v 2500k -maxrate 2500k -bufsize 2500k -filter:v scale="trunc(oha/2)2:720" -sws_flags lanczos+accurate_rnd -acodec libfdk_aac -b:a 96k -ar 48000 -ac 2 -f flv rtmp://localhost/live2/688ecdd5-44ba-4445-95a2-624d58c420bd 80 | -------------------------------------------------------------------------------- /amf/decoder.go: -------------------------------------------------------------------------------- 1 | package amf 2 | 3 | import ( 4 | "encoding/binary" 5 | "math" 6 | ) 7 | 8 | var rtmpCommandParams = map[string][]string{ 9 | "connect": []string{"transId", "cmdObj", "args"}, 10 | "_result": []string{"transId", "cmdObj", "info"}, 11 | "releaseStream": []string{"transId", "cmdObj", "streamName"}, 12 | "createStream": []string{"transId", "cmdObj"}, 13 | "publish": []string{"transId", "cmdObj", "streamName", "type"}, 14 | "FCPublish": []string{"transId", "cmdObj", "streamName"}, 15 | "FCUnpublish": []string{"transId", "cmdObj", "streamName"}, 16 | "onFCPublish": []string{"transId", "cmdObj", "info"}, 17 | "@setDataFrame": []string{"method", "dataObj"}, 18 | "play": []string{"transId", "cmdObj", "streamName", "start", "duration", "reset"}, 19 | } 20 | 21 | // DecodedData .. 22 | type DecodedData struct { 23 | remainingData []byte 24 | value interface{} 25 | } 26 | 27 | func getDecoder(data []byte) func() DecodedData { 28 | typeMarker := uint8(data[0]) 29 | var decoder func([]byte) DecodedData 30 | switch typeMarker { 31 | case 0x00: 32 | decoder = decodeNumber 33 | 34 | case 0x01: 35 | decoder = decodeBool 36 | 37 | case 0x02: 38 | decoder = decodeString 39 | 40 | case 0x03: 41 | decoder = decodeObject 42 | 43 | case 0x05: 44 | decoder = decodeNull 45 | 46 | case 0x08: 47 | decoder = decodeECMAArray 48 | } 49 | 50 | return func() DecodedData { 51 | return decoder(data[1:]) 52 | } 53 | } 54 | 55 | // Decode AMF0 command object 56 | func Decode(data []byte) map[string]interface{} { 57 | decoded := getDecoder(data)() 58 | cmd := map[string]interface{}{ 59 | "cmd": decoded.value, 60 | } 61 | params := rtmpCommandParams[decoded.value.(string)] 62 | 63 | for _, param := range params { 64 | if len(decoded.remainingData) > 0 { 65 | decoded = getDecoder(decoded.remainingData)() 66 | cmd[param] = decoded.value 67 | } 68 | } 69 | 70 | return cmd 71 | } 72 | 73 | func decodeNumber(data []byte) DecodedData { 74 | return DecodedData{ 75 | remainingData: data[8:], 76 | value: math.Float64frombits(binary.BigEndian.Uint64(data[:8])), 77 | } 78 | } 79 | 80 | func decodeBool(data []byte) DecodedData { 81 | return DecodedData{ 82 | remainingData: data[1:], 83 | value: data[0] != 0, 84 | } 85 | } 86 | 87 | func decodeString(data []byte) DecodedData { 88 | len := binary.BigEndian.Uint16(data[:2]) 89 | return DecodedData{ 90 | remainingData: data[2+len:], 91 | value: string(data[2 : 2+len]), 92 | } 93 | } 94 | 95 | func decodeObject(data []byte) DecodedData { 96 | object := make(map[string]interface{}) 97 | tData := data 98 | for len(tData) != 0 { 99 | decoded := decodeString(tData) 100 | key := decoded.value.(string) 101 | tData = decoded.remainingData 102 | 103 | if uint8(tData[0]) == 0x09 { 104 | tData = tData[1:] 105 | break 106 | } 107 | 108 | decoded = getDecoder(tData)() 109 | tData = decoded.remainingData 110 | object[key] = decoded.value 111 | } 112 | 113 | return DecodedData{ 114 | remainingData: tData, 115 | value: object, 116 | } 117 | } 118 | 119 | func decodeNull(data []byte) DecodedData { 120 | return DecodedData{ 121 | remainingData: data, 122 | value: nil, 123 | } 124 | } 125 | 126 | func decodeECMAArray(data []byte) DecodedData { 127 | decoded := decodeObject(data[4:]) 128 | return DecodedData{ 129 | remainingData: decoded.remainingData, 130 | value: decoded.value, 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /models/user_model.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "errors" 5 | "regexp" 6 | "time" 7 | 8 | jwt "github.com/dgrijalva/jwt-go" 9 | "github.com/jinzhu/gorm" 10 | ) 11 | 12 | // Model base 13 | type Model struct { 14 | ID int `json:"id" gorm:"primary_key"` 15 | CreatedAt time.Time `json:"-"` 16 | UpdatedAt time.Time `json:"-"` 17 | DeletedAt *time.Time `json:"-" sql:"index"` 18 | } 19 | 20 | // User .. 21 | type User struct { 22 | Model 23 | Name string `json:"name"` 24 | Email string `json:"email" gorm:"unique"` 25 | Password string `json:"password"` 26 | IsVerified bool `json:"isVerified"` 27 | VerificationToken string `json:"-"` 28 | Streams []Stream `json:"streams" gorm:"foreignkey:UserID"` 29 | } 30 | 31 | // PublicUserData has the public information of an user 32 | type PublicUserData struct { 33 | *User 34 | Password string `json:"password,omitempty"` 35 | } 36 | 37 | // CanRegister validates the user for registration 38 | func (u User) CanRegister(db *gorm.DB) (errorMap map[string]string) { 39 | errorMap = make(map[string]string) 40 | if u.Name == "" { 41 | errorMap["name"] = "Name cannot be blank" 42 | } 43 | 44 | var c int 45 | 46 | if u.Email == "" { 47 | errorMap["email"] = "Email cannot be blank" 48 | } else if re := regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$"); !re.MatchString(u.Email) { 49 | errorMap["email"] = "Given email is not valid" 50 | } else if db.Model(&User{}).Where(&User{Email: u.Email}).Count(&c); c != 0 { 51 | errorMap["email"] = "Email address already in use" 52 | } 53 | 54 | if u.Password == "" { 55 | errorMap["password"] = "Password cannot be blank" 56 | } else if len(u.Password) < 6 { 57 | errorMap["password"] = "Password must be atleast 6 characters long" 58 | } 59 | 60 | return 61 | } 62 | 63 | // Register creates a new user in database 64 | func (u *User) Register(db *gorm.DB) error { 65 | u.VerificationToken = "12345" 66 | return db.Create(u).Error 67 | } 68 | 69 | // SendVerificationEmail sends the verification link in email 70 | func (u User) SendVerificationEmail() error { 71 | return nil 72 | } 73 | 74 | // VerifyUser marks the user as verified 75 | func (u *User) VerifyUser(db *gorm.DB, token string) error { 76 | if u.VerificationToken != token { 77 | return errors.New("Invalid token") 78 | } 79 | 80 | u.IsVerified = true 81 | u.VerificationToken = "" 82 | 83 | return db.Save(u).Error 84 | } 85 | 86 | // GetJWT generates a JWT token for the user 87 | func (u User) GetJWT() (string, error) { 88 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ 89 | "email": u.Email, 90 | "id": u.ID, 91 | }) 92 | return token.SignedString([]byte("Secret")) 93 | } 94 | 95 | // CanLogin checks validates user for login 96 | func (u *User) CanLogin(db *gorm.DB) (errorMap map[string]string) { 97 | errorMap = make(map[string]string) 98 | if u.Email == "" { 99 | errorMap["email"] = "Email is required" 100 | } 101 | 102 | if u.Password == "" { 103 | errorMap["password"] = "Password is required" 104 | } 105 | 106 | db.Where(u).First(u) 107 | if u.ID == 0 { 108 | errorMap["login"] = "Invalid username/password" 109 | } 110 | 111 | return 112 | } 113 | 114 | // FindByEmail returns a user for an given email 115 | func (u *User) FindByEmail(db *gorm.DB) error { 116 | return db.Where("email = ?", u.Email).First(u).Error 117 | } 118 | -------------------------------------------------------------------------------- /controllers/users.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | "github.com/praveen001/go-rtmp-web-server/models" 8 | ) 9 | 10 | // RegisterUser adds a new user to database 11 | func (c *ApplicationContext) RegisterUser(w http.ResponseWriter, r *http.Request) { 12 | user := &models.User{} 13 | if err := json.NewDecoder(r.Body).Decode(user); err != nil { 14 | c.NewResponse(w).Status(400).SendJSON(Response{ 15 | Error: true, 16 | Message: "Unable to parse request", 17 | }) 18 | return 19 | } 20 | 21 | if errorMap := user.CanRegister(c.DB); len(errorMap) != 0 { 22 | c.NewResponse(w).Status(400).SendJSON(Response{ 23 | Error: true, 24 | Message: "Validation error", 25 | Data: errorMap, 26 | }) 27 | return 28 | } 29 | 30 | if err := user.Register(c.DB); err != nil { 31 | c.NewResponse(w).Status(200).SendJSON(Response{ 32 | Error: true, 33 | Message: "Unknown error occured while registering", 34 | }) 35 | return 36 | } 37 | 38 | if err := user.SendVerificationEmail(); err != nil { 39 | // Just Log and Ignore 40 | } 41 | 42 | token, _ := user.GetJWT() 43 | 44 | c.NewResponse(w).Status(200).SendJSON(Response{ 45 | Message: "Successfully Registered", 46 | Data: map[string]interface{}{ 47 | "token": token, 48 | "user": models.PublicUserData{User: user}, 49 | }, 50 | }) 51 | } 52 | 53 | // VerifyUser marks an user as verified user 54 | func (c *ApplicationContext) VerifyUser(w http.ResponseWriter, r *http.Request) { 55 | query := r.URL.Query() 56 | email := query.Get("email") 57 | token := query.Get("token") 58 | 59 | if email == "" || token == "" { 60 | c.NewResponse(w).Status(200).SendJSON(Response{ 61 | Error: true, 62 | Message: "Missing email/token", 63 | }) 64 | return 65 | } 66 | 67 | user := models.User{ 68 | Email: email, 69 | } 70 | user.FindByEmail(c.DB) 71 | if err := user.VerifyUser(c.DB, token); err != nil { 72 | c.NewResponse(w).Status(403).SendJSON(Response{ 73 | Error: true, 74 | Message: err.Error(), 75 | }) 76 | return 77 | } 78 | 79 | c.NewResponse(w).Status(200).SendJSON(Response{ 80 | Message: "Successfully verified", 81 | }) 82 | } 83 | 84 | // LoginUser login handler 85 | func (c *ApplicationContext) LoginUser(w http.ResponseWriter, r *http.Request) { 86 | user := &models.User{} 87 | if err := json.NewDecoder(r.Body).Decode(user); err != nil { 88 | c.NewResponse(w).Status(http.StatusBadRequest).SendJSON(Response{ 89 | Error: true, 90 | Message: "Unable to parse request", 91 | }) 92 | return 93 | } 94 | 95 | if errorMap := user.CanLogin(c.DB); len(errorMap) != 0 { 96 | c.NewResponse(w).Status(http.StatusOK).SendJSON(Response{ 97 | Error: true, 98 | Message: "Validation error", 99 | Data: errorMap, 100 | }) 101 | return 102 | } 103 | 104 | token, _ := user.GetJWT() 105 | 106 | c.NewResponse(w).Status(200).SendJSON(Response{ 107 | Message: "Successfully logged in", 108 | Data: map[string]interface{}{ 109 | "token": token, 110 | "user": models.PublicUserData{User: user}, 111 | }, 112 | }) 113 | } 114 | 115 | // TokenInfo returns information about user by decoding the JWT token 116 | func (c *ApplicationContext) TokenInfo(w http.ResponseWriter, r *http.Request) { 117 | user := &models.User{ 118 | Model: models.Model{ 119 | ID: int(r.Context().Value("id").(float64)), 120 | }, 121 | } 122 | c.DB.Find(&user, user) 123 | 124 | c.NewResponse(w).Status(200).SendJSON(Response{ 125 | Data: models.PublicUserData{User: user}, 126 | }) 127 | } 128 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "os" 8 | "os/signal" 9 | 10 | "github.com/gomodule/redigo/redis" 11 | "google.golang.org/grpc" 12 | 13 | "github.com/praveen001/go-rtmp-grpc/pkg/api/v1" 14 | "github.com/praveen001/go-rtmp-web-server/controllers" 15 | "github.com/praveen001/go-rtmp-web-server/models" 16 | "github.com/praveen001/go-rtmp-web-server/router" 17 | "github.com/praveen001/go-rtmp-web-server/rtmp" 18 | 19 | "github.com/jinzhu/gorm" 20 | _ "github.com/jinzhu/gorm/dialects/mysql" 21 | ) 22 | 23 | func main() { 24 | switch os.Args[1] { 25 | case "rtmp": 26 | rtmpServer() 27 | case "web": 28 | webServer() 29 | default: 30 | fmt.Println("Server mode should be streamer or web") 31 | } 32 | } 33 | 34 | func rtmpServer() { 35 | conn, err := grpc.Dial(fmt.Sprintf("%s:%s", os.Getenv("GRPC_HOST"), "4005"), grpc.WithInsecure()) 36 | if err != nil { 37 | fmt.Println("GRPC Connection Error") 38 | return 39 | } 40 | rpcClient := v1.NewUserChannelServiceClient(conn) 41 | 42 | redisClient, err := redis.Dial("tcp", fmt.Sprintf("%s:%s", os.Getenv("REDIS_HOST"), "6379")) 43 | if err != nil { 44 | panic("Unable to connect to Redis" + err.Error()) 45 | } 46 | 47 | ctx := &rtmp.StreamerContext{ 48 | RPC: rpcClient, 49 | Redis: redisClient, 50 | } 51 | 52 | rtmp.InitServer(ctx) 53 | 54 | c := make(chan os.Signal, 1) 55 | signal.Notify(c, os.Interrupt) 56 | 57 | // Block till we receive an interrupt 58 | <-c 59 | 60 | os.Exit(0) 61 | } 62 | 63 | func webServer() { 64 | controllers.InitYoutube() 65 | controllers.InitTwitch() 66 | fmt.Println(os.Environ()) 67 | 68 | // MySQL DB 69 | db, err := gorm.Open("mysql", fmt.Sprintf("%s:%s@tcp(%s)/%s?parseTime=true", os.Getenv("MYSQL_USER"), os.Getenv("MYSQL_PASSWORD"), os.Getenv("MYSQL_HOST"), os.Getenv("MYSQL_DATABASE"))) 70 | if err != nil { 71 | panic("Unable to connect to database " + err.Error()) 72 | } 73 | db.AutoMigrate(&models.User{}, &models.Stream{}, &models.Channel{}) 74 | defer db.Close() 75 | 76 | // Websocket 77 | hub := controllers.NewHub() 78 | go hub.Run() 79 | 80 | // Redis PubSub 81 | redisPool := &redis.Pool{ 82 | MaxIdle: 80, 83 | MaxActive: 100, 84 | Dial: func() (redis.Conn, error) { 85 | c, err := redis.Dial("tcp", fmt.Sprintf("%s:%s", os.Getenv("REDIS_HOST"), "6379")) 86 | if err != nil { 87 | panic("Redis connection failed " + err.Error()) 88 | } 89 | return c, err 90 | }, 91 | } 92 | pubsub := controllers.NewPubSub(redisPool.Get(), "Stream") 93 | go pubsub.Listen(hub) 94 | defer pubsub.Close() 95 | 96 | appContext := &controllers.ApplicationContext{ 97 | DB: db, 98 | Hub: hub, 99 | PubSub: pubsub, 100 | RedisPool: redisPool, 101 | } 102 | 103 | // Grpc 104 | go controllers.NewRPCServer(appContext) 105 | 106 | // Create a HTTP Server instance 107 | srv := &http.Server{ 108 | Addr: fmt.Sprintf("0.0.0.0:5000"), 109 | Handler: router.New(appContext), 110 | } 111 | 112 | // Run the server in a goroutine 113 | go func() { 114 | if err := srv.ListenAndServe(); err != nil { 115 | panic(err) 116 | } 117 | }() 118 | // go rtmp.InitServer(db) 119 | fmt.Println("Started server at port 5000") 120 | 121 | // Create a channel to listen for OS Interrupts 122 | c := make(chan os.Signal, 1) 123 | signal.Notify(c, os.Interrupt) 124 | 125 | // Block till we receive an interrupt 126 | <-c 127 | 128 | // Wait for timeout to complete 129 | ctx, cancel := context.WithTimeout(context.Background(), 30) 130 | defer cancel() 131 | 132 | // Shutdown the server 133 | srv.Shutdown(ctx) 134 | os.Exit(0) 135 | } 136 | -------------------------------------------------------------------------------- /controllers/websocket.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | 9 | jwt "github.com/dgrijalva/jwt-go" 10 | "github.com/gorilla/websocket" 11 | ) 12 | 13 | func checkOrigin(r *http.Request) bool { 14 | return true 15 | } 16 | 17 | var upgrader = websocket.Upgrader{ 18 | CheckOrigin: checkOrigin, 19 | } 20 | 21 | var ( 22 | newline = []byte{'\n'} 23 | space = []byte{' '} 24 | ) 25 | 26 | // WSOutgoing .. 27 | type WSOutgoing struct { 28 | ID int 29 | Message []byte 30 | } 31 | 32 | // WSHub .. 33 | type WSHub struct { 34 | Clients map[int]map[int]*WSClient 35 | Register chan *WSClient 36 | Unregister chan *WSClient 37 | Incoming chan []byte 38 | Outgoing chan WSOutgoing 39 | ID int 40 | } 41 | 42 | // NewHub .. 43 | func NewHub() *WSHub { 44 | return &WSHub{ 45 | Clients: make(map[int]map[int]*WSClient), 46 | Register: make(chan *WSClient), 47 | Unregister: make(chan *WSClient), 48 | Incoming: make(chan []byte, 10), 49 | Outgoing: make(chan WSOutgoing, 10), 50 | } 51 | } 52 | 53 | // Run . 54 | func (h *WSHub) Run() { 55 | for { 56 | select { 57 | case client := <-h.Register: 58 | h.ID++ 59 | client.Index = h.ID 60 | if h.Clients[client.ID] == nil { 61 | h.Clients[client.ID] = make(map[int]*WSClient) 62 | } 63 | fmt.Println("Connected", client.ID) 64 | h.Clients[client.ID][h.ID] = client 65 | 66 | case client := <-h.Unregister: 67 | fmt.Println("Disconnected", client.ID) 68 | delete(h.Clients[client.ID], client.Index) 69 | close(client.Send) 70 | 71 | case message := <-h.Incoming: 72 | fmt.Println("Got a message", message) 73 | 74 | case outgoing := <-h.Outgoing: 75 | fmt.Println("Got a message - 1", outgoing) 76 | if h.Clients[outgoing.ID] != nil { 77 | for _, client := range h.Clients[outgoing.ID] { 78 | client.Send <- outgoing.Message 79 | } 80 | } 81 | } 82 | } 83 | } 84 | 85 | // WSClient .. 86 | type WSClient struct { 87 | ID int 88 | Index int 89 | Hub *WSHub 90 | Conn *websocket.Conn 91 | Send chan []byte 92 | } 93 | 94 | // Reader .. 95 | func (wc *WSClient) Reader() { 96 | defer func() { 97 | wc.Hub.Unregister <- wc 98 | wc.Conn.Close() 99 | }() 100 | 101 | wc.Conn.SetReadLimit(512) 102 | for { 103 | _, message, err := wc.Conn.ReadMessage() 104 | if err != nil { 105 | fmt.Println("Read Error", err.Error()) 106 | break 107 | } 108 | 109 | message = bytes.TrimSpace(bytes.Replace(message, newline, space, -1)) 110 | wc.Hub.Incoming <- message 111 | } 112 | } 113 | 114 | // Writer .. 115 | func (wc *WSClient) Writer() { 116 | for { 117 | select { 118 | case message := <-wc.Send: 119 | wc.Conn.WriteMessage(websocket.TextMessage, message) 120 | } 121 | } 122 | } 123 | 124 | // HandleWebsocket .. 125 | func (c *ApplicationContext) HandleWebsocket(w http.ResponseWriter, r *http.Request) { 126 | tokenString := r.FormValue("token") 127 | token, err := VerifyJWT(tokenString) 128 | if err != nil { 129 | log.Println("Unauthenticated ", err.Error()) 130 | c.NewResponse(w).Status(403) 131 | return 132 | } 133 | claims := token.Claims.(jwt.MapClaims) 134 | id := int(claims["id"].(float64)) 135 | 136 | conn, err := upgrader.Upgrade(w, r, nil) 137 | if err != nil { 138 | log.Println("Unable to upgrade websocket connection ", err.Error()) 139 | return 140 | } 141 | 142 | client := &WSClient{ 143 | ID: id, 144 | Hub: c.Hub, 145 | Conn: conn, 146 | Send: make(chan []byte, 512), 147 | } 148 | client.Hub.Register <- client 149 | 150 | go client.Reader() 151 | go client.Writer() 152 | } 153 | 154 | // VerifyJWT decodes a JWT and returns the token 155 | func VerifyJWT(tokenString string) (*jwt.Token, error) { 156 | return jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { 157 | return []byte("Secret"), nil 158 | }) 159 | } 160 | -------------------------------------------------------------------------------- /controllers/youtube.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "os" 7 | "strconv" 8 | 9 | "github.com/google/uuid" 10 | "github.com/praveen001/go-rtmp-web-server/models" 11 | "golang.org/x/net/context" 12 | "golang.org/x/oauth2" 13 | "golang.org/x/oauth2/google" 14 | "google.golang.org/api/youtube/v3" 15 | ) 16 | 17 | var conf *oauth2.Config 18 | 19 | // InitYoutube will initialize a youtube config 20 | func InitYoutube() { 21 | conf = &oauth2.Config{ 22 | ClientID: os.Getenv("YOUTUBE_CLIENT_ID"), 23 | ClientSecret: os.Getenv("YOUTUBE_CLIENT_SECRET"), 24 | RedirectURL: os.Getenv("API_ENDPOINT") + "/v1/api/channels/youtube/callback", 25 | Scopes: []string{ 26 | "https://www.googleapis.com/auth/userinfo.email", 27 | "https://www.googleapis.com/auth/youtube.readonly", 28 | "https://www.googleapis.com/auth/youtubepartner-channel-audit", 29 | }, 30 | Endpoint: google.Endpoint, 31 | } 32 | } 33 | 34 | // GoogleAuth .. 35 | func (c *ApplicationContext) GoogleAuth(w http.ResponseWriter, r *http.Request) { 36 | redisConn := c.RedisPool.Get() 37 | defer redisConn.Close() 38 | 39 | id := uuid.New().String() 40 | userID := int(r.Context().Value("id").(float64)) 41 | streamID, err := strconv.Atoi(r.FormValue("streamId")) 42 | if err != nil { 43 | c.NewResponse(w).Status(400).SendJSON(Response{ 44 | Error: true, 45 | Message: "Invalid Stream ID", 46 | }) 47 | return 48 | } 49 | 50 | // Check whether stream belongs to user 51 | stream := &models.Stream{ 52 | Model: models.Model{ 53 | ID: streamID, 54 | }, 55 | } 56 | c.DB.Find(stream, stream) 57 | 58 | if stream.UserID != userID { 59 | c.NewResponse(w).Status(403).SendJSON(Response{ 60 | Error: true, 61 | Message: "Unauthorized", 62 | }) 63 | return 64 | } 65 | 66 | redisConn.Do("SET", id, streamID) 67 | http.Redirect(w, r, conf.AuthCodeURL(id), http.StatusSeeOther) 68 | } 69 | 70 | // GoogleAuthCallback .. 71 | func (c *ApplicationContext) GoogleAuthCallback(w http.ResponseWriter, r *http.Request) { 72 | redisConn := c.RedisPool.Get() 73 | defer redisConn.Close() 74 | 75 | id := r.FormValue("state") 76 | rawStreamID, err := redisConn.Do("GET", id) 77 | redisConn.Do("DELETE", id) 78 | if err != nil { 79 | fmt.Println("Error occured") 80 | return 81 | } 82 | streamID, _ := strconv.Atoi(string(rawStreamID.([]byte))) 83 | 84 | code := r.FormValue("code") 85 | 86 | // Exchange authorization code for access token 87 | token, err := conf.Exchange(context.Background(), code) 88 | if err != nil { 89 | fmt.Println("Error occured", err.Error()) 90 | return 91 | } 92 | 93 | // Create a youtube api client 94 | y, _ := youtube.New(conf.Client(context.Background(), token)) 95 | 96 | // live broadcast list api call to get default live broadcast 97 | liveBroadcastListResponse, err := y.LiveBroadcasts.List([]string{"id", "status", "snippet", "contentDetails"}).BroadcastType("persistent").Mine(true).Do() 98 | if err != nil { 99 | c.NewResponse(w).Status(200).SendJSON(Response{ 100 | Data: err.Error(), 101 | }) 102 | return 103 | } 104 | boundStreamID := liveBroadcastListResponse.Items[0].ContentDetails.BoundStreamId 105 | 106 | // live stream list api call to get default live stream associated to default live broadcast using bound stream id of live broadcast 107 | liveStreamListResponse, err := y.LiveStreams.List([]string{"id", "status", "snippet", "cdn"}).Id(boundStreamID).Do() 108 | if err != nil { 109 | c.NewResponse(w).Status(200).SendJSON(Response{ 110 | Data: err.Error(), 111 | }) 112 | return 113 | } 114 | defaultStream := liveStreamListResponse.Items[0] 115 | 116 | // Create the youtube channel 117 | channel := &models.Channel{ 118 | Name: "Youtube", 119 | URL: defaultStream.Cdn.IngestionInfo.IngestionAddress, 120 | Key: defaultStream.Cdn.IngestionInfo.StreamName, 121 | StreamID: streamID, 122 | Enabled: true, 123 | } 124 | 125 | // Add the channel and save it 126 | c.DB.Save(channel) 127 | http.Redirect(w, r, os.Getenv("UI_ENDPOINT")+"/streams/"+strconv.Itoa(streamID)+"/dashboard", http.StatusSeeOther) 128 | } 129 | -------------------------------------------------------------------------------- /rtmp/chunk.go: -------------------------------------------------------------------------------- 1 | package rtmp 2 | 3 | import ( 4 | "encoding/binary" 5 | "math" 6 | 7 | "github.com/praveen001/joy4/utils/bits/pio" 8 | ) 9 | 10 | type rtmpChunk struct { 11 | header *chunkHeader 12 | clock uint32 13 | delta uint32 14 | payload []byte 15 | bytes int 16 | capacity uint32 17 | } 18 | 19 | type chunkHeader struct { 20 | fmt uint8 21 | csid uint32 22 | timestamp uint32 23 | length uint32 24 | messageType uint8 25 | messageStreamID uint32 26 | hasExtendedTimestamp bool 27 | } 28 | 29 | var chunkHeaderSize = map[uint8]int{ 30 | 0: 11, 31 | 1: 7, 32 | 2: 3, 33 | 3: 0, 34 | } 35 | 36 | func (c *Connection) createRtmpChunk(fmt uint8, csid uint32) *rtmpChunk { 37 | header := &chunkHeader{ 38 | fmt: fmt, 39 | csid: csid, 40 | timestamp: 0, 41 | length: 0, 42 | messageType: 0, 43 | messageStreamID: 0, 44 | hasExtendedTimestamp: false, 45 | } 46 | 47 | chunk := &rtmpChunk{ 48 | header: header, 49 | clock: 0, 50 | delta: 0, 51 | bytes: 0, 52 | capacity: 0, 53 | } 54 | 55 | return chunk 56 | } 57 | 58 | func (c Connection) create(chunk *rtmpChunk) [][]byte { 59 | basicHeader := chunk.createBasicHeader() 60 | messageHeader := chunk.createMessageHeader() 61 | extendedTimestamp := chunk.createExtendedTimestamp() 62 | payloads := chunk.createPayloadArray(c) 63 | 64 | chunks := make([][]byte, len(payloads)) 65 | var bytes []byte 66 | bytes = append(bytes, basicHeader...) 67 | bytes = append(bytes, messageHeader...) 68 | bytes = append(bytes, extendedTimestamp...) 69 | bytes = append(bytes, payloads[0]...) 70 | 71 | chunks[0] = bytes 72 | 73 | for i := 1; i < len(payloads); i++ { 74 | tempChunk := rtmpChunk{ 75 | header: &chunkHeader{ 76 | fmt: 3, 77 | csid: chunk.header.csid, 78 | }, 79 | payload: payloads[i], 80 | } 81 | basicHeader = tempChunk.createBasicHeader() 82 | 83 | chunks[i] = append(basicHeader, payloads[i]...) 84 | } 85 | 86 | return chunks 87 | } 88 | 89 | func (chunk rtmpChunk) createBasicHeader() []byte { 90 | var res []byte 91 | if chunk.header.csid >= 64+255 { 92 | res = make([]byte, 3) 93 | res[0] = (chunk.header.fmt << 6) | 1 94 | res[1] = uint8((chunk.header.csid - 64) & 0xFF) 95 | res[2] = uint8(((chunk.header.csid - 64) >> 8) & 0xFF) 96 | } else if chunk.header.csid >= 64 { 97 | res = make([]byte, 2) 98 | res[0] = (chunk.header.fmt << 6) | 0 99 | res[1] = uint8((chunk.header.csid - 64) & 0xFF) 100 | } else { 101 | res = make([]byte, 1) 102 | res[0] = (chunk.header.fmt << 6) | uint8(chunk.header.csid) 103 | } 104 | 105 | return res 106 | } 107 | 108 | func (chunk *rtmpChunk) createMessageHeader() []byte { 109 | res := make([]byte, chunkHeaderSize[chunk.header.fmt]) 110 | 111 | if chunk.header.fmt <= 2 { 112 | if chunk.header.timestamp >= 0xffffff { 113 | pio.PutU24BE(res, 0xffffff) 114 | chunk.header.hasExtendedTimestamp = true 115 | } else { 116 | pio.PutU24BE(res, chunk.header.timestamp) 117 | } 118 | } 119 | 120 | if chunk.header.fmt <= 1 { 121 | pio.PutU24BE(res[3:], chunk.header.length) 122 | res[6] = chunk.header.messageType 123 | } 124 | 125 | if chunk.header.fmt == 0 { 126 | binary.LittleEndian.PutUint32(res[7:], chunk.header.messageStreamID) 127 | } 128 | 129 | return res 130 | } 131 | 132 | func (chunk rtmpChunk) createExtendedTimestamp() []byte { 133 | if chunk.header.hasExtendedTimestamp { 134 | res := make([]byte, 4) 135 | binary.BigEndian.PutUint32(res, chunk.header.timestamp) 136 | return res 137 | } 138 | return make([]byte, 0) 139 | } 140 | 141 | func (chunk rtmpChunk) createPayloadArray(c Connection) [][]byte { 142 | totalChunks := int(math.Ceil(float64(float64(chunk.header.length) / float64(c.WriteMaxChunkSize)))) 143 | if totalChunks == 0 { 144 | totalChunks = 1 145 | } 146 | payloads := make([][]byte, totalChunks) 147 | 148 | offset := 0 149 | for i := 0; i < totalChunks; i++ { 150 | size := int(chunk.header.length) - offset 151 | if size > c.WriteMaxChunkSize { 152 | size = c.WriteMaxChunkSize 153 | } 154 | payloads[i] = chunk.payload[offset : offset+size] 155 | offset += size 156 | } 157 | 158 | return payloads 159 | } 160 | -------------------------------------------------------------------------------- /controllers/twitch.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "net/http" 8 | "os" 9 | "strconv" 10 | 11 | "github.com/google/uuid" 12 | "github.com/praveen001/go-rtmp-web-server/models" 13 | ) 14 | 15 | var clientID string 16 | var clientSecret string 17 | var redirectURL string 18 | 19 | // InitTwitch will initialize twitch api configs 20 | func InitTwitch() { 21 | clientID = os.Getenv("TWITCH_CLIENT_ID") 22 | clientSecret = os.Getenv("TWITCH_CLIENT_SECRET") 23 | redirectURL = os.Getenv("API_ENDPOINT") + "/v1/api/channels/twitch/callback" 24 | } 25 | 26 | // TwitchAuth .. 27 | func (c *ApplicationContext) TwitchAuth(w http.ResponseWriter, r *http.Request) { 28 | redisConn := c.RedisPool.Get() 29 | defer redisConn.Close() 30 | 31 | id := uuid.New().String() 32 | userID := int(r.Context().Value("id").(float64)) 33 | streamID, err := strconv.Atoi(r.FormValue("streamId")) 34 | if err != nil { 35 | c.NewResponse(w).Status(400).SendJSON(Response{ 36 | Error: true, 37 | Message: "Invalid Stream ID", 38 | }) 39 | return 40 | } 41 | 42 | // Check whether stream belongs to user 43 | stream := &models.Stream{ 44 | Model: models.Model{ 45 | ID: streamID, 46 | }, 47 | } 48 | c.DB.Find(stream, stream) 49 | 50 | if stream.UserID != userID { 51 | c.NewResponse(w).Status(403).SendJSON(Response{ 52 | Error: true, 53 | Message: "Unauthorized", 54 | }) 55 | return 56 | } 57 | 58 | redisConn.Do("SET", id, streamID) 59 | http.Redirect(w, r, "https://id.twitch.tv/oauth2/authorize?client_id="+clientID+"&redirect_uri="+redirectURL+"&response_type=code&scope=channel:read:stream_key channel_read user_read channel_editor channel_stream&state="+id, http.StatusSeeOther) 60 | } 61 | 62 | // TwitchAuthCallback .. 63 | func (c *ApplicationContext) TwitchAuthCallback(w http.ResponseWriter, r *http.Request) { 64 | redisConn := c.RedisPool.Get() 65 | defer redisConn.Close() 66 | 67 | id := r.FormValue("state") 68 | rawStreamID, err := redisConn.Do("GET", id) 69 | redisConn.Do("DELETE", id) 70 | if err != nil { 71 | fmt.Println("Error occured", err) 72 | return 73 | } 74 | streamID, _ := strconv.Atoi(string(rawStreamID.([]byte))) 75 | 76 | token := r.FormValue("code") 77 | 78 | client := &http.Client{} 79 | 80 | // Exchange authorization code for access token 81 | req, _ := http.NewRequest("POST", "https://id.twitch.tv/oauth2/token?client_id="+clientID+"&client_secret="+clientSecret+"&code="+token+"&grant_type=authorization_code&redirect_uri="+redirectURL, nil) 82 | res, err := client.Do(req) 83 | if err != nil { 84 | fmt.Println("Twitch Request Error 1", err) 85 | return 86 | } 87 | 88 | // Read the body 89 | body, _ := ioutil.ReadAll(res.Body) 90 | var response1 struct { 91 | AccessToken string `json:"access_token"` 92 | } 93 | 94 | if err := json.Unmarshal(body, &response1); err != nil { 95 | fmt.Println("Unable to read response 1", err) 96 | } 97 | accessToken := response1.AccessToken 98 | 99 | // Validate the access token 100 | req, _ = http.NewRequest("GET", "https://id.twitch.tv/oauth2/validate", nil) 101 | req.Header.Add("Authorization", "OAuth "+accessToken) 102 | res, err = client.Do(req) 103 | if err != nil { 104 | fmt.Println("Unable to validate twitch access token", err) 105 | } 106 | 107 | body, _ = ioutil.ReadAll(res.Body) 108 | var response2 struct { 109 | UserID string `json:"user_id"` 110 | } 111 | 112 | if err := json.Unmarshal(body, &response2); err != nil { 113 | fmt.Println("Unable to read response 2", err) 114 | } 115 | twitchUserID := response2.UserID 116 | 117 | // Get Stream Key 118 | req, _ = http.NewRequest("GET", "https://api.twitch.tv/helix/streams/key?broadcaster_id="+twitchUserID, nil) 119 | req.Header.Add("Client-ID", clientID) 120 | req.Header.Add("Authorization", "Bearer "+accessToken) 121 | res, err = client.Do(req) 122 | if err != nil { 123 | fmt.Println("Twitch Request Error 2", err) 124 | } 125 | 126 | body, _ = ioutil.ReadAll(res.Body) 127 | var response3 struct { 128 | Data []struct { 129 | StreamKey string `json:"stream_key"` 130 | } `json:"data"` 131 | } 132 | 133 | if err := json.Unmarshal(body, &response3); err != nil { 134 | fmt.Println("Unable to read response 3", err) 135 | } 136 | 137 | channel := &models.Channel{ 138 | Name: "Twitch", 139 | URL: "rtmp://live-sin.twitch.tv/app", 140 | Key: response3.Data[0].StreamKey, 141 | StreamID: streamID, 142 | Enabled: true, 143 | } 144 | c.DB.Save(channel) 145 | 146 | http.Redirect(w, r, os.Getenv("UI_ENDPOINT")+"/streams/"+strconv.Itoa(streamID)+"/dashboard", http.StatusSeeOther) 147 | } 148 | -------------------------------------------------------------------------------- /frontend/src/components/Streams/Streams.tsx: -------------------------------------------------------------------------------- 1 | import Button from '@material-ui/core/Button'; 2 | import Paper from '@material-ui/core/Paper'; 3 | import { 4 | createStyles, 5 | Theme, 6 | withStyles, 7 | WithStyles 8 | } from '@material-ui/core/styles'; 9 | import Typography from '@material-ui/core/Typography'; 10 | import classnames from 'classnames'; 11 | import React from 'react'; 12 | import NewStreamContainer from '../../containers/NewStreamContainer'; 13 | import { ApiStatus, IStats, IStream } from '../../models'; 14 | import VideoPlayer from '../Player/Player'; 15 | import Title from '../Title/Title'; 16 | 17 | const styles = (theme: Theme) => 18 | createStyles({ 19 | wrap: { 20 | display: 'flex', 21 | flexWrap: 'wrap', 22 | '& > :nth-child(3n)': { 23 | paddingRight: 0 24 | } 25 | }, 26 | streamWrap: { 27 | paddingRight: theme.spacing.unit, 28 | paddingBottom: theme.spacing.unit, 29 | width: `33.3%` 30 | }, 31 | stream: { 32 | padding: theme.spacing.unit 33 | }, 34 | streamOnline: { 35 | border: '1px solid green' 36 | }, 37 | player: { 38 | background: '#000', 39 | height: 250, 40 | width: '100%', 41 | marginBottom: theme.spacing.unit 42 | }, 43 | controls: { 44 | display: 'flex', 45 | justifyContent: 'space-between', 46 | alignItems: 'center' 47 | }, 48 | actions: { 49 | display: 'flex', 50 | '& > *': { 51 | marginLeft: theme.spacing.unit 52 | } 53 | } 54 | }); 55 | 56 | class Streams extends React.Component { 57 | deleteStream = (streamId: number) => () => { 58 | this.props.deleteStream(streamId); 59 | }; 60 | 61 | getOptions = (stream: IStream) => { 62 | const videoJsOptions = { 63 | autoplay: true, 64 | controls: false, 65 | muted: true, 66 | sources: [ 67 | { 68 | src: `/hls-preview/${stream.key}/index.m3u8`, 69 | type: 'application/x-mpegURL' 70 | } 71 | ] 72 | }; 73 | 74 | return videoJsOptions; 75 | }; 76 | 77 | render() { 78 | const { classes, streams, statsById, openNewStreamDialog } = this.props; 79 | 80 | return ( 81 |
82 | 83 | <Typography variant="button">My Streams</Typography> 84 | <Button 85 | variant="contained" 86 | color="primary" 87 | onClick={openNewStreamDialog} 88 | > 89 | Add Stream 90 | </Button> 91 | 92 |
93 | {streams.map(stream => ( 94 |
95 | 102 |
103 | {statsById[stream.id] && statsById[stream.id].online && ( 104 | 105 | )} 106 |
107 |
108 | {stream.name} 109 |
110 | 118 | 126 |
127 |
128 |
129 |
130 | ))} 131 |
132 | 133 |
134 | ); 135 | } 136 | } 137 | 138 | export default withStyles(styles)(Streams); 139 | 140 | export interface IStreamsStateProps { 141 | streams: IStream[]; 142 | statsById: { [key: number]: IStats }; 143 | } 144 | 145 | export interface IStreamsDispatchProps { 146 | openNewStreamDialog: () => {}; 147 | deleteStream: (streamId: number) => void; 148 | } 149 | 150 | type StreamsProps = IStreamsStateProps & 151 | IStreamsDispatchProps & 152 | WithStyles; 153 | -------------------------------------------------------------------------------- /frontend/src/components/AddChannel/AddChannel.tsx: -------------------------------------------------------------------------------- 1 | import ButtonBase from '@material-ui/core/ButtonBase'; 2 | import Dialog from '@material-ui/core/Dialog'; 3 | import Divider from '@material-ui/core/Divider'; 4 | import IconButton from '@material-ui/core/IconButton'; 5 | import { 6 | createStyles, 7 | Theme, 8 | withStyles, 9 | WithStyles 10 | } from '@material-ui/core/styles'; 11 | import Typography from '@material-ui/core/Typography'; 12 | import CloseIcon from '@material-ui/icons/Close'; 13 | import React from 'react'; 14 | import { BaseUrl } from '../../config'; 15 | import ChannelContainer from '../../containers/ChannelContainer'; 16 | import Facebook from '../Icons/Facebook'; 17 | import Periscope from '../Icons/Periscope'; 18 | import Twitch from '../Icons/Twitch'; 19 | import Youtube from '../Icons/Youtube'; 20 | import Title from '../Title/Title'; 21 | 22 | const styles = (theme: Theme) => 23 | createStyles({ 24 | dialog: { 25 | padding: theme.spacing.unit 26 | }, 27 | wrap: { 28 | display: 'flex', 29 | padding: theme.spacing.unit 30 | }, 31 | channelListWrap: { 32 | display: 'flex', 33 | justifyContent: 'center', 34 | flexDirection: 'column' 35 | }, 36 | channelList: { 37 | display: 'flex', 38 | flexWrap: 'wrap' 39 | }, 40 | channelTile: { 41 | width: 150, 42 | height: 120, 43 | background: '#333', 44 | display: 'flex', 45 | alignItems: 'center', 46 | justifyContent: 'center', 47 | flexDirection: 'column', 48 | color: '#fff', 49 | margin: theme.spacing.unit 50 | }, 51 | logo: { 52 | width: '100%' 53 | }, 54 | title: { 55 | flex: 1 56 | } 57 | }); 58 | 59 | class AddChannel extends React.Component { 60 | add = (name: string) => () => { 61 | window.location.replace( 62 | `${BaseUrl}/channels/${name}?streamId=${this.props.streamId}` 63 | ); 64 | }; 65 | 66 | render() { 67 | const { classes, open, hideAddChannel } = this.props; 68 | 69 | return ( 70 | 79 | 80 | <Typography variant="button">Add Channel</Typography> 81 | <IconButton onClick={hideAddChannel}> 82 | <CloseIcon /> 83 | </IconButton> 84 | 85 |
86 | 87 |
88 | 89 | Choose from 90 | 91 |
92 |
93 | 97 | 98 | 99 | Youtube 100 | 101 | 102 | 106 | 107 | 108 | Twitch 109 | 110 | 111 | 115 | 116 | 117 | Facebook 118 | 119 | 120 | 124 | 125 | 126 | Periscope 127 | 128 | 129 |
130 |
131 |
132 |
133 | ); 134 | } 135 | } 136 | 137 | export default withStyles(styles)(AddChannel); 138 | 139 | export interface IAddChannelStatusProps { 140 | open: boolean; 141 | streamId: number; 142 | } 143 | 144 | export interface IAddChannelDispatchProps { 145 | hideAddChannel: () => {}; 146 | } 147 | 148 | type AddChannelProps = IAddChannelStatusProps & 149 | IAddChannelDispatchProps & 150 | WithStyles; 151 | -------------------------------------------------------------------------------- /frontend/src/actions/channelActions.ts: -------------------------------------------------------------------------------- 1 | import { IChannel } from '../models'; 2 | 3 | export enum ChannelActionTypes { 4 | ADD_CHANNEL = 'channels/Add', 5 | ADDING_CHANNEL = 'channels/addingChannel', 6 | ADDED_CHANNEL = 'channels/addedChannel', 7 | FETCH_CHANNEL_LIST = 'channels/fetch', 8 | FETCHING_CHANNEL_LIST = 'channels/fetchingList', 9 | FETCHED_CHANNEL_LIST = 'channels/fetchedList', 10 | SHOW_ADD_CHANNEL = 'channels/showAddChannel', 11 | HIDE_ADD_CHANNEL = 'channels/hideAddChannel', 12 | DELETE_CHANNEL = 'channels/deleteChannel', 13 | DELETE_CHANNEL_FAILURE = 'channels/deleteChannelFailure', 14 | DELETE_CHANNEL_SUCCESS = 'channels/deleteChannelSuccess' 15 | } 16 | 17 | export function addChannel( 18 | streamId: number, 19 | name: string, 20 | url: string, 21 | key: string 22 | ): IAddChannelAction { 23 | return { 24 | type: ChannelActionTypes.ADD_CHANNEL, 25 | payload: { 26 | streamId, 27 | name, 28 | url, 29 | key 30 | } 31 | }; 32 | } 33 | 34 | export function addingChannel(): IAddingChannelAction { 35 | return { type: ChannelActionTypes.ADDING_CHANNEL }; 36 | } 37 | 38 | export function addedChannel(): IAddedChannelAction { 39 | return { type: ChannelActionTypes.ADDED_CHANNEL }; 40 | } 41 | 42 | export function fetchChannelList(streamId: string): IFetchChannelListAction { 43 | return { 44 | type: ChannelActionTypes.FETCH_CHANNEL_LIST, 45 | payload: { 46 | streamId 47 | } 48 | }; 49 | } 50 | 51 | export function fetchingChannelList(): IFetchingChannelListAction { 52 | return { 53 | type: ChannelActionTypes.FETCHING_CHANNEL_LIST 54 | }; 55 | } 56 | 57 | export function fetchedChannelList( 58 | channels: IChannel[] 59 | ): IFetchedChannelListAction { 60 | return { 61 | type: ChannelActionTypes.FETCHED_CHANNEL_LIST, 62 | payload: { channels } 63 | }; 64 | } 65 | 66 | export function showAddChannel(): IShowAddChannelAction { 67 | return { 68 | type: ChannelActionTypes.SHOW_ADD_CHANNEL 69 | }; 70 | } 71 | 72 | export function hideAddChannel(): IHideAddChannelAction { 73 | return { 74 | type: ChannelActionTypes.HIDE_ADD_CHANNEL 75 | }; 76 | } 77 | 78 | export function deleteChannel( 79 | streamId: number, 80 | channelId: number 81 | ): IDeleteChannelAction { 82 | return { 83 | type: ChannelActionTypes.DELETE_CHANNEL, 84 | payload: { 85 | streamId, 86 | channelId 87 | } 88 | }; 89 | } 90 | 91 | export function deleteChannelFailure(): IDeleteChannelFailureAction { 92 | return { 93 | type: ChannelActionTypes.DELETE_CHANNEL_FAILURE 94 | }; 95 | } 96 | 97 | export function deleteChannelSuccess( 98 | streamId: number, 99 | channelId: number 100 | ): IDeleteChannelSuccessAction { 101 | return { 102 | type: ChannelActionTypes.DELETE_CHANNEL_SUCCESS, 103 | payload: { streamId, channelId } 104 | }; 105 | } 106 | 107 | export interface IAddChannelAction { 108 | type: ChannelActionTypes.ADD_CHANNEL; 109 | payload: { 110 | streamId: number; 111 | name: string; 112 | url: string; 113 | key: string; 114 | }; 115 | } 116 | 117 | export interface IAddingChannelAction { 118 | type: ChannelActionTypes.ADDING_CHANNEL; 119 | } 120 | 121 | export interface IAddedChannelAction { 122 | type: ChannelActionTypes.ADDED_CHANNEL; 123 | } 124 | 125 | export interface IFetchChannelListAction { 126 | type: ChannelActionTypes.FETCH_CHANNEL_LIST; 127 | payload: { 128 | streamId: string; 129 | }; 130 | } 131 | 132 | export interface IFetchingChannelListAction { 133 | type: ChannelActionTypes.FETCHING_CHANNEL_LIST; 134 | } 135 | 136 | export interface IFetchedChannelListAction { 137 | type: ChannelActionTypes.FETCHED_CHANNEL_LIST; 138 | payload: { 139 | channels: IChannel[]; 140 | }; 141 | } 142 | 143 | export interface IShowAddChannelAction { 144 | type: ChannelActionTypes.SHOW_ADD_CHANNEL; 145 | } 146 | 147 | export interface IHideAddChannelAction { 148 | type: ChannelActionTypes.HIDE_ADD_CHANNEL; 149 | } 150 | 151 | export interface IDeleteChannelAction { 152 | type: ChannelActionTypes.DELETE_CHANNEL; 153 | payload: { 154 | streamId: number; 155 | channelId: number; 156 | }; 157 | } 158 | 159 | export interface IDeleteChannelFailureAction { 160 | type: ChannelActionTypes.DELETE_CHANNEL_FAILURE; 161 | } 162 | 163 | export interface IDeleteChannelSuccessAction { 164 | type: ChannelActionTypes.DELETE_CHANNEL_SUCCESS; 165 | payload: { 166 | streamId: number; 167 | channelId: number; 168 | }; 169 | } 170 | 171 | export type ChannelAction = 172 | | IAddChannelAction 173 | | IAddingChannelAction 174 | | IAddedChannelAction 175 | | IFetchChannelListAction 176 | | IFetchingChannelListAction 177 | | IFetchedChannelListAction 178 | | IShowAddChannelAction 179 | | IHideAddChannelAction 180 | | IDeleteChannelAction 181 | | IDeleteChannelFailureAction 182 | | IDeleteChannelSuccessAction; 183 | -------------------------------------------------------------------------------- /controllers/app_context.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "log" 9 | "net/http" 10 | 11 | jwt "github.com/dgrijalva/jwt-go" 12 | "github.com/go-chi/chi/middleware" 13 | "github.com/gomodule/redigo/redis" 14 | "github.com/rs/cors" 15 | 16 | "github.com/jinzhu/gorm" 17 | ) 18 | 19 | // 813004830141-2leap7abkjf00bsofqcdn6a6po8l9348.apps.googleusercontent.com 20 | 21 | // ApplicationContext data which has to be passed to all the controllers. 22 | // 23 | // Note all the data you put into this are safe for concurrent access 24 | type ApplicationContext struct { 25 | DB *gorm.DB 26 | Hub *WSHub 27 | PubSub *PubSub 28 | RedisPool *redis.Pool 29 | } 30 | 31 | // HandlerFunc is a custom handler 32 | type HandlerFunc func(ResponseWriter, *http.Request) 33 | 34 | // ResponseWriter is our custom wrapper over http.ResponseWriter 35 | type ResponseWriter struct { 36 | http.ResponseWriter 37 | } 38 | 39 | // Response is the object used as a json template for http response 40 | type Response struct { 41 | Message string `json:"message"` 42 | Data interface{} `json:"data"` 43 | Error bool `json:"error"` 44 | } 45 | 46 | // Write calls http.ResponseWriter.Write 47 | // It will write bytes into response 48 | func (r ResponseWriter) Write(b []byte) (int, error) { 49 | return r.ResponseWriter.Write(b) 50 | } 51 | 52 | // WriteHeader calls http.ResponseWriter.WriteHeader 53 | // Sets http status code 54 | func (r ResponseWriter) WriteHeader(statusCode int) { 55 | r.ResponseWriter.WriteHeader(statusCode) 56 | } 57 | 58 | // Header calls http.ResponseWriter.Header 59 | // Returns http header 60 | func (r ResponseWriter) Header() http.Header { 61 | return r.ResponseWriter.Header() 62 | } 63 | 64 | // Status will set the http status code, and returns the same object 65 | func (r ResponseWriter) Status(statusCode int) ResponseWriter { 66 | r.ResponseWriter.WriteHeader(statusCode) 67 | return r 68 | } 69 | 70 | // SendJSON will write JSON data to response 71 | func (r ResponseWriter) SendJSON(data Response) error { 72 | if err := json.NewEncoder(r.ResponseWriter).Encode(data); err != nil { 73 | return err 74 | } 75 | return nil 76 | } 77 | 78 | // NewResponse .. 79 | func (c *ApplicationContext) NewResponse(w http.ResponseWriter) ResponseWriter { 80 | return ResponseWriter{w} 81 | } 82 | 83 | // RecoveryHandler returns 500 status when handler panics. 84 | // Writes error to application log 85 | func (c *ApplicationContext) RecoveryHandler(h http.Handler) http.Handler { 86 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 87 | var err error 88 | defer func() { 89 | if e := recover(); e != nil { 90 | switch t := e.(type) { 91 | case string: 92 | err = errors.New(t) 93 | case error: 94 | err = t 95 | default: 96 | err = errors.New("Unknown error") 97 | } 98 | log.Panicln(err.Error()) 99 | http.Error(w, err.Error(), http.StatusInternalServerError) 100 | } 101 | }() 102 | 103 | h.ServeHTTP(w, r) 104 | }) 105 | } 106 | 107 | // CORSHandler handles cors requests 108 | func (c *ApplicationContext) CORSHandler(h http.Handler) http.Handler { 109 | return cors.New(cors.Options{ 110 | AllowedOrigins: []string{"*"}, 111 | AllowedHeaders: []string{"authorization", "content-type"}, 112 | AllowedMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete}, 113 | }).Handler(h) 114 | } 115 | 116 | // LogHandler writes access log 117 | func (c *ApplicationContext) LogHandler(h http.Handler) http.Handler { 118 | return middleware.DefaultLogger(h) 119 | } 120 | 121 | // Authentication middleware decode the jwt token found in 'Authorization' header 122 | // and add the decoded information to the request context 123 | func (c *ApplicationContext) Authentication(next http.HandlerFunc) http.HandlerFunc { 124 | return func(w http.ResponseWriter, r *http.Request) { 125 | authToken := r.Header.Get("Authorization") 126 | if authToken == "" { 127 | authToken = getCookieByName(r.Cookies(), "token") 128 | } 129 | 130 | token, err := VerifyJWT(authToken) 131 | if err != nil || !token.Valid { 132 | c.NewResponse(w).Status(403).SendJSON(Response{ 133 | Error: true, 134 | Message: "Unauthorized", 135 | }) 136 | return 137 | } 138 | 139 | claims := token.Claims.(jwt.MapClaims) 140 | ctx := context.WithValue(r.Context(), "email", claims["email"]) 141 | ctx = context.WithValue(ctx, "id", claims["id"]) 142 | next(w, r.WithContext(ctx)) 143 | } 144 | } 145 | 146 | // Logging logs the incoming requests 147 | func Logging(next http.HandlerFunc) http.HandlerFunc { 148 | return func(w http.ResponseWriter, r *http.Request) { 149 | fmt.Println(r.Method, r.RequestURI) 150 | next(w, r) 151 | } 152 | } 153 | 154 | func getCookieByName(cookie []*http.Cookie, name string) string { 155 | cookieLen := len(cookie) 156 | result := "" 157 | for i := 0; i < cookieLen; i++ { 158 | if cookie[i].Name == name { 159 | result = cookie[i].Value 160 | break 161 | } 162 | } 163 | return result 164 | } 165 | -------------------------------------------------------------------------------- /frontend/src/actions/streamActions.ts: -------------------------------------------------------------------------------- 1 | import { IStream } from '../models'; 2 | 3 | export enum StreamActionTypes { 4 | LOAD_STREAMS = 'streams/loadStreams', 5 | LOAD_STREAMS_FAILURE = 'streams/loadStreamsFailure', 6 | LOAD_STREAMS_SUCCESS = 'streams/loadStreamsSuccess', 7 | OPEN_NEW_STREAM_DIALOG = 'streams/openNewStreamDialog', 8 | CLOSE_NEW_STREAM_DIALOG = 'streams/closeNewStreamDialog', 9 | CREATE_STREAM = 'streams/createStream', 10 | CREATE_STREAM_FAILURE = 'streams/createStreamFailure', 11 | CREATE_SUCCESS_SUCCESS = 'streams/createStreamSuccess', 12 | SELECT_STREAM = 'streams/selectStream', 13 | DELETE_STREAM = 'streams/deleteStream', 14 | DELETE_STREAM_FAILURE = 'streams/deleteStreamFailure', 15 | DELETE_STREAM_SUCCESS = 'streams/deleteStreamSuccess' 16 | } 17 | 18 | export function loadStreams(): ILoadStreamsAction { 19 | return { type: StreamActionTypes.LOAD_STREAMS }; 20 | } 21 | 22 | export function loadStreamsFailure(): ILoadStreamsFailureAction { 23 | return { type: StreamActionTypes.LOAD_STREAMS_FAILURE }; 24 | } 25 | 26 | export function loadStreamsSuccess( 27 | streams: IStream[] 28 | ): ILoadStreamsSuccessAction { 29 | return { type: StreamActionTypes.LOAD_STREAMS_SUCCESS, payload: { streams } }; 30 | } 31 | 32 | export function openNewStreamDialog() { 33 | return { 34 | type: StreamActionTypes.OPEN_NEW_STREAM_DIALOG 35 | }; 36 | } 37 | 38 | export function closeNewStreamDialog() { 39 | return { 40 | type: StreamActionTypes.CLOSE_NEW_STREAM_DIALOG 41 | }; 42 | } 43 | 44 | export function createStream(name: string): ICreateStreamAction { 45 | return { 46 | type: StreamActionTypes.CREATE_STREAM, 47 | payload: { 48 | name 49 | } 50 | }; 51 | } 52 | 53 | export function createStreamFailure(): ICreateStreamFailureAction { 54 | return { 55 | type: StreamActionTypes.CREATE_STREAM_FAILURE 56 | }; 57 | } 58 | 59 | export function createStreamSuccess(): ICreateStreamSuccessAction { 60 | return { 61 | type: StreamActionTypes.CREATE_SUCCESS_SUCCESS 62 | }; 63 | } 64 | 65 | export function selectStream(streamId: string): ISelectStreamAction { 66 | return { 67 | type: StreamActionTypes.SELECT_STREAM, 68 | payload: { 69 | streamId: parseInt(streamId, 10) 70 | } 71 | }; 72 | } 73 | 74 | export function deleteStream(streamId: number): IDeleteStreamAction { 75 | return { 76 | type: StreamActionTypes.DELETE_STREAM, 77 | payload: { 78 | streamId 79 | } 80 | }; 81 | } 82 | 83 | export function deleteStreamFailure( 84 | streamId: number 85 | ): IDeleteStreamFailureAction { 86 | return { 87 | type: StreamActionTypes.DELETE_STREAM_FAILURE, 88 | payload: { 89 | streamId 90 | } 91 | }; 92 | } 93 | 94 | export function deleteStreamSuccess( 95 | streamId: number 96 | ): IDeleteStreamSuccessAction { 97 | return { 98 | type: StreamActionTypes.DELETE_STREAM_SUCCESS, 99 | payload: { 100 | streamId 101 | } 102 | }; 103 | } 104 | 105 | export interface ILoadStreamsAction { 106 | type: StreamActionTypes.LOAD_STREAMS; 107 | } 108 | 109 | export interface ILoadStreamsFailureAction { 110 | type: StreamActionTypes.LOAD_STREAMS_FAILURE; 111 | } 112 | 113 | export interface ILoadStreamsSuccessAction { 114 | type: StreamActionTypes.LOAD_STREAMS_SUCCESS; 115 | payload: { 116 | streams: IStream[]; 117 | }; 118 | } 119 | 120 | export interface IOpenNewStreamDialogAction { 121 | type: StreamActionTypes.OPEN_NEW_STREAM_DIALOG; 122 | } 123 | 124 | export interface ICloseNewStreamDialogAction { 125 | type: StreamActionTypes.CLOSE_NEW_STREAM_DIALOG; 126 | } 127 | 128 | export interface ICreateStreamAction { 129 | type: StreamActionTypes.CREATE_STREAM; 130 | payload: { 131 | name: string; 132 | }; 133 | } 134 | 135 | export interface ICreateStreamFailureAction { 136 | type: StreamActionTypes.CREATE_STREAM_FAILURE; 137 | } 138 | 139 | export interface ICreateStreamSuccessAction { 140 | type: StreamActionTypes.CREATE_SUCCESS_SUCCESS; 141 | } 142 | 143 | export interface ISelectStreamAction { 144 | type: StreamActionTypes.SELECT_STREAM; 145 | payload: { 146 | streamId: number; 147 | }; 148 | } 149 | 150 | export interface IDeleteStreamAction { 151 | type: StreamActionTypes.DELETE_STREAM; 152 | payload: { 153 | streamId: number; 154 | }; 155 | } 156 | 157 | export interface IDeleteStreamFailureAction { 158 | type: StreamActionTypes.DELETE_STREAM_FAILURE; 159 | payload: { 160 | streamId: number; 161 | }; 162 | } 163 | 164 | export interface IDeleteStreamSuccessAction { 165 | type: StreamActionTypes.DELETE_STREAM_SUCCESS; 166 | payload: { 167 | streamId: number; 168 | }; 169 | } 170 | 171 | export type StreamAction = 172 | | ILoadStreamsAction 173 | | ILoadStreamsFailureAction 174 | | ILoadStreamsSuccessAction 175 | | IOpenNewStreamDialogAction 176 | | ICloseNewStreamDialogAction 177 | | ICreateStreamAction 178 | | ICreateStreamFailureAction 179 | | ICreateStreamSuccessAction 180 | | ISelectStreamAction 181 | | IDeleteStreamAction 182 | | IDeleteStreamFailureAction 183 | | IDeleteStreamSuccessAction; 184 | -------------------------------------------------------------------------------- /scripts/wait-for-it.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Use this script to test if a given TCP host/port are available 3 | 4 | WAITFORIT_cmdname=${0##*/} 5 | 6 | echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi } 7 | 8 | usage() 9 | { 10 | cat << USAGE >&2 11 | Usage: 12 | $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args] 13 | -h HOST | --host=HOST Host or IP under test 14 | -p PORT | --port=PORT TCP port under test 15 | Alternatively, you specify the host and port as host:port 16 | -s | --strict Only execute subcommand if the test succeeds 17 | -q | --quiet Don't output any status messages 18 | -t TIMEOUT | --timeout=TIMEOUT 19 | Timeout in seconds, zero for no timeout 20 | -- COMMAND ARGS Execute command with args after the test finishes 21 | USAGE 22 | exit 1 23 | } 24 | 25 | wait_for() 26 | { 27 | if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then 28 | echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" 29 | else 30 | echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout" 31 | fi 32 | WAITFORIT_start_ts=$(date +%s) 33 | while : 34 | do 35 | if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then 36 | nc -z $WAITFORIT_HOST $WAITFORIT_PORT 37 | WAITFORIT_result=$? 38 | else 39 | (echo > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1 40 | WAITFORIT_result=$? 41 | fi 42 | if [[ $WAITFORIT_result -eq 0 ]]; then 43 | WAITFORIT_end_ts=$(date +%s) 44 | echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds" 45 | break 46 | fi 47 | sleep 1 48 | done 49 | return $WAITFORIT_result 50 | } 51 | 52 | wait_for_wrapper() 53 | { 54 | # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692 55 | if [[ $WAITFORIT_QUIET -eq 1 ]]; then 56 | timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & 57 | else 58 | timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & 59 | fi 60 | WAITFORIT_PID=$! 61 | trap "kill -INT -$WAITFORIT_PID" INT 62 | wait $WAITFORIT_PID 63 | WAITFORIT_RESULT=$? 64 | if [[ $WAITFORIT_RESULT -ne 0 ]]; then 65 | echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" 66 | fi 67 | return $WAITFORIT_RESULT 68 | } 69 | 70 | # process arguments 71 | while [[ $# -gt 0 ]] 72 | do 73 | case "$1" in 74 | *:* ) 75 | WAITFORIT_hostport=(${1//:/ }) 76 | WAITFORIT_HOST=${WAITFORIT_hostport[0]} 77 | WAITFORIT_PORT=${WAITFORIT_hostport[1]} 78 | shift 1 79 | ;; 80 | --child) 81 | WAITFORIT_CHILD=1 82 | shift 1 83 | ;; 84 | -q | --quiet) 85 | WAITFORIT_QUIET=1 86 | shift 1 87 | ;; 88 | -s | --strict) 89 | WAITFORIT_STRICT=1 90 | shift 1 91 | ;; 92 | -h) 93 | WAITFORIT_HOST="$2" 94 | if [[ $WAITFORIT_HOST == "" ]]; then break; fi 95 | shift 2 96 | ;; 97 | --host=*) 98 | WAITFORIT_HOST="${1#*=}" 99 | shift 1 100 | ;; 101 | -p) 102 | WAITFORIT_PORT="$2" 103 | if [[ $WAITFORIT_PORT == "" ]]; then break; fi 104 | shift 2 105 | ;; 106 | --port=*) 107 | WAITFORIT_PORT="${1#*=}" 108 | shift 1 109 | ;; 110 | -t) 111 | WAITFORIT_TIMEOUT="$2" 112 | if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi 113 | shift 2 114 | ;; 115 | --timeout=*) 116 | WAITFORIT_TIMEOUT="${1#*=}" 117 | shift 1 118 | ;; 119 | --) 120 | shift 121 | WAITFORIT_CLI=("$@") 122 | break 123 | ;; 124 | --help) 125 | usage 126 | ;; 127 | *) 128 | echoerr "Unknown argument: $1" 129 | usage 130 | ;; 131 | esac 132 | done 133 | 134 | if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then 135 | echoerr "Error: you need to provide a host and port to test." 136 | usage 137 | fi 138 | 139 | WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15} 140 | WAITFORIT_STRICT=${WAITFORIT_STRICT:-0} 141 | WAITFORIT_CHILD=${WAITFORIT_CHILD:-0} 142 | WAITFORIT_QUIET=${WAITFORIT_QUIET:-0} 143 | 144 | # check to see if timeout is from busybox? 145 | WAITFORIT_TIMEOUT_PATH=$(type -p timeout) 146 | WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH) 147 | if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then 148 | WAITFORIT_ISBUSY=1 149 | WAITFORIT_BUSYTIMEFLAG="-t" 150 | 151 | else 152 | WAITFORIT_ISBUSY=0 153 | WAITFORIT_BUSYTIMEFLAG="" 154 | fi 155 | 156 | if [[ $WAITFORIT_CHILD -gt 0 ]]; then 157 | wait_for 158 | WAITFORIT_RESULT=$? 159 | exit $WAITFORIT_RESULT 160 | else 161 | if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then 162 | wait_for_wrapper 163 | WAITFORIT_RESULT=$? 164 | else 165 | wait_for 166 | WAITFORIT_RESULT=$? 167 | fi 168 | fi 169 | 170 | if [[ $WAITFORIT_CLI != "" ]]; then 171 | if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then 172 | echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess" 173 | exit $WAITFORIT_RESULT 174 | fi 175 | exec "${WAITFORIT_CLI[@]}" 176 | else 177 | exit $WAITFORIT_RESULT 178 | fi --------------------------------------------------------------------------------