550 lines
17 KiB
Go
550 lines
17 KiB
Go
package handlers
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"log"
|
||
"net/http"
|
||
"os"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"git.insit.tech/psa/rtsp_reader-writer/reader/internal/config"
|
||
"git.insit.tech/psa/rtsp_reader-writer/reader/internal/processor"
|
||
"git.insit.tech/psa/rtsp_reader-writer/writer/pkg/storage"
|
||
jwtware "github.com/gofiber/contrib/jwt"
|
||
"github.com/gofiber/fiber/v2"
|
||
"github.com/golang-jwt/jwt/v5"
|
||
"github.com/sirupsen/logrus"
|
||
)
|
||
|
||
type VideoRequest struct {
|
||
Date string `json:"date"`
|
||
StartTime string `json:"start_time"`
|
||
EndTime string `json:"end_time"`
|
||
}
|
||
|
||
type ListVodsResponse struct {
|
||
EstimatedCount int `json:"estimated_count"` // Estimated total number of records for the query.
|
||
Vods []string `json:"vods"` // List of vods.
|
||
Files map[string][]string `json:"files"` // List files and folders of a specific VOD location.
|
||
}
|
||
|
||
type ConfigVodsResponse struct {
|
||
Prefix string `json:"prefix"` // The unique name of VOD location.
|
||
AutoMbr bool `json:"auto_mbr"` // Turns on automatic creation of a multi-bitrate HLS playlist from several files with different bitrates.
|
||
Disabled bool `json:"disabled"` // Whether this VOD location is disabled.
|
||
Protocols struct { // Configuraton of play protocols.
|
||
Hls bool `json:"hls"` // Whether to allow or deny an HLS stream playback.
|
||
Cmaf bool `json:"cmaf"` // Whether to allow or deny an LL-HLS stream playback.
|
||
Dash bool `json:"dash"` // Whether to allow or deny a DASH stream playback.
|
||
Player bool `json:"player"` // Whether to allow or deny playback in embed.html.
|
||
Mss bool `json:"mss"` // Whether to allow or deny an MSS stream playback.
|
||
Rtmp bool `json:"rtmp"` // Whether to allow or deny an RTMP stream playback.
|
||
Rtsp bool `json:"rtsp"` // Whether to allow or deny an RTSP stream playback.
|
||
M4F bool `json:"m4f"` // Whether to allow or deny an M4F stream playback.
|
||
M4S bool `json:"m4s"` // Whether to allow or deny an M4S stream playback.
|
||
Mseld bool `json:"mseld"` // Whether to allow or deny an MSE-LD stream playback.
|
||
Tshttp bool `json:"tshttp"` // Whether to allow or deny an MPEG-TS stream playback over HTTP(S).
|
||
Webrtc bool `json:"webrtc"` // Whether to allow or deny an WebRTC stream playback.
|
||
Srt bool `json:"srt"` // Whether to allow or deny an SRT stream playback.
|
||
Shoutcast bool `json:"shoutcast"` // Whether to allow or deny a SHOUTcast/Icecast stream playback.
|
||
Mp4 bool `json:"mp4"` // Whether to allow or deny an MP4 file download over HTTP(S).
|
||
Jpeg bool `json:"jpeg"` // Whether to allow or deny delivering JPEG thumbnails over HTTP(S).
|
||
Api bool `json:"api"` // Whether to allow or deny API requests.
|
||
} `json:"protocols"`
|
||
SegmentDuration int `json:"segment_duration"` // The time, in seconds, of the segment duration. Used for the protocols like HLS or DASH.
|
||
AddAudioOnly bool `json:"add_audio_only"` // Whether to add an audio-only version of an HLS stream. Used to create App Store compliant HLS streams to deliver the content to Apple iOS devices. Add audio-only HLS playlist to variant MBR playlist for iOS compliant streaming.
|
||
Provider string `json:"provider"` // Human-readable name of the content provider. Applicable to MPEG-TS.
|
||
}
|
||
|
||
type SingleVodsResponse struct {
|
||
Name string `json:"name"`
|
||
Prefix string `json:"prefix"`
|
||
Url string `json:"url"`
|
||
Folder string `json:"folder"`
|
||
Bytes int64 `json:"bytes"`
|
||
MediaInfo MediaSingleVodsResponse `json:"media_info"`
|
||
}
|
||
|
||
type MediaSingleVodsResponse struct {
|
||
Tracks map[string]string `json:"tracks"`
|
||
Duration int `json:"duration"`
|
||
Provider string `json:"provider"`
|
||
Title string `json:"title"`
|
||
}
|
||
|
||
// Inactive5Minutes checks if a folder has files that was not created or modified for last 5 minutes.
|
||
func Inactive5Minutes(entries []os.DirEntry) (bool, error) {
|
||
threshold := time.Now().Add(-5 * time.Minute)
|
||
|
||
disabled := true
|
||
|
||
for _, entry := range entries {
|
||
info, err := entry.Info()
|
||
if err != nil {
|
||
return true, errors.New("Info error: " + err.Error())
|
||
}
|
||
|
||
if info.ModTime().After(threshold) {
|
||
disabled = false
|
||
return disabled, nil
|
||
}
|
||
}
|
||
|
||
return true, nil
|
||
}
|
||
|
||
// recDurationMilliseconds calculates the difference between first existing rec file and the last one in milliseconds.
|
||
func recDurationMilliseconds(entries []os.DirEntry) (int, error) {
|
||
// Find last file and first file; get timestamps.
|
||
lastFile := entries[len(entries)-1].Name()
|
||
lastTime := strings.Split(lastFile, "_")[0]
|
||
|
||
firstFile := entries[0].Name()
|
||
firstTime := strings.Split(firstFile, "_")[0]
|
||
|
||
// Convert string timestamps to int.
|
||
lastTimeInt, err := strconv.Atoi(lastTime)
|
||
if err != nil {
|
||
return 0, errors.New("convert last time error: " + err.Error())
|
||
}
|
||
|
||
firstTimeInt, err := strconv.Atoi(firstTime)
|
||
if err != nil {
|
||
return 0, errors.New("convert first time error: " + err.Error())
|
||
}
|
||
|
||
// Calculate the difference.
|
||
difference := lastTimeInt - firstTimeInt
|
||
|
||
return difference * 1000, nil
|
||
}
|
||
|
||
// Download processes Download request.
|
||
func Download(w http.ResponseWriter, r *http.Request) {
|
||
log.Printf("new download request: %+v\n", r)
|
||
|
||
downloadRequest := VideoRequest{}
|
||
|
||
err := json.NewDecoder(r.Body).Decode(&downloadRequest)
|
||
if err != nil {
|
||
log.Printf("json decode error: %v\n", err)
|
||
w.WriteHeader(http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
pathFileNameRes, err := processor.Process(downloadRequest.Date, downloadRequest.StartTime, downloadRequest.EndTime)
|
||
if err != nil {
|
||
log.Printf("process error: %v\n", err)
|
||
w.WriteHeader(http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "video/mp4")
|
||
// Разрешаем частичную загрузку (поддержка перемотки)
|
||
w.Header().Set("Accept-Ranges", "bytes")
|
||
|
||
http.ServeFile(w, r, pathFileNameRes)
|
||
}
|
||
|
||
// HLS processes Download request.
|
||
func HLS(w http.ResponseWriter, r *http.Request) {
|
||
log.Printf("new hls request: %+v\n", r)
|
||
|
||
path := "/home/psa/GoRepository/data/1280x720/"
|
||
|
||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||
http.StripPrefix("/hls", http.FileServer(http.Dir(path))).ServeHTTP(w, r)
|
||
}
|
||
|
||
// ListVodsHandler returns the list of VOD locations.
|
||
//
|
||
// This method allows to get the list of all VOD locations. VOD location is a virtual filepath used to place files for
|
||
// VOD (Video on Demand) broadcasting.
|
||
func ListVodsHandler(c *fiber.Ctx) error {
|
||
// Read directory.
|
||
entries, err := os.ReadDir(config.DirData)
|
||
if err != nil {
|
||
log.Println("StatusInternalServerError")
|
||
return c.SendStatus(fiber.StatusInternalServerError)
|
||
}
|
||
|
||
// Filter only folders.
|
||
var dirs []string
|
||
for _, entry := range entries {
|
||
if entry.IsDir() {
|
||
dirs = append(dirs, entry.Name())
|
||
}
|
||
}
|
||
|
||
// Prepare the Response.
|
||
VodsRes := ListVodsResponse{
|
||
EstimatedCount: len(dirs),
|
||
Vods: dirs,
|
||
}
|
||
|
||
// Write header and code response.
|
||
return c.JSON(VodsRes)
|
||
}
|
||
|
||
// ConfigVodsHandler returns configuration of the requested VOD location.
|
||
//
|
||
// This method allows to get a single VOD location.
|
||
func ConfigVodsHandler(c *fiber.Ctx) error {
|
||
// Read camera id.
|
||
id := c.Params("id")
|
||
|
||
// Get resolutions.
|
||
resolutions, err := storage.GetResolutions(id)
|
||
if err != nil {
|
||
return c.SendStatus(fiber.StatusBadRequest)
|
||
}
|
||
|
||
// Calculate response fields.
|
||
|
||
// Create path to first resolution.
|
||
resDir := fmt.Sprintf("%s/%s/%s", config.DirData, id, resolutions[0])
|
||
|
||
// Read directory.
|
||
entries, err := os.ReadDir(resDir)
|
||
if err != nil {
|
||
return c.SendStatus(fiber.StatusInternalServerError)
|
||
}
|
||
|
||
// Check if a folder has files that was not created or modified for last 5 minutes.
|
||
disabled, err := Inactive5Minutes(entries)
|
||
if err != nil {
|
||
return c.SendStatus(fiber.StatusInternalServerError)
|
||
}
|
||
|
||
// Calculate the difference between first existing rec file and the last one in milliseconds.
|
||
segmentDuration, err := recDurationMilliseconds(entries)
|
||
if err != nil {
|
||
return c.SendStatus(fiber.StatusInternalServerError)
|
||
}
|
||
|
||
// Prepare the Response.
|
||
VodsRes := ConfigVodsResponse{
|
||
Prefix: id,
|
||
AutoMbr: false, // Always false. Temporary.
|
||
Disabled: disabled,
|
||
SegmentDuration: segmentDuration,
|
||
AddAudioOnly: false, // Always false. Temporary.
|
||
Provider: "Insit",
|
||
}
|
||
|
||
// Write header and code response.
|
||
return c.JSON(VodsRes)
|
||
}
|
||
|
||
// DelVodsHandler delete archive of the requested VOD location.
|
||
//
|
||
// This method delete a single VOD location by its prefix.
|
||
func DelVodsHandler(c *fiber.Ctx) error {
|
||
// Read camera id.
|
||
id := c.Params("id")
|
||
|
||
err := os.Remove(fmt.Sprintf("%s/%s", config.DirData, id))
|
||
if err != nil {
|
||
return c.SendStatus(fiber.StatusBadRequest)
|
||
}
|
||
|
||
return c.SendStatus(fiber.StatusNoContent)
|
||
}
|
||
|
||
// ListFilesVodsHandler returns the list of all files and folders in archive for a specific VOD location.
|
||
//
|
||
// This method allows to get the list of all files and folders for a specific VOD location.
|
||
func ListFilesVodsHandler(c *fiber.Ctx) error {
|
||
// Read camera id.
|
||
id := c.Params("id")
|
||
|
||
// Create map for response.
|
||
files := make(map[string][]string)
|
||
|
||
// Get resolutions.
|
||
resolutions, err := storage.GetResolutions(id)
|
||
if err != nil {
|
||
return c.SendStatus(fiber.StatusBadRequest)
|
||
}
|
||
|
||
for _, resolution := range resolutions {
|
||
// Create path to the resolutions.
|
||
resDir := fmt.Sprintf("%s/%s/%s", config.DirData, id, resolution)
|
||
|
||
// Read directory.
|
||
entries, err := os.ReadDir(resDir)
|
||
if err != nil {
|
||
return c.SendStatus(fiber.StatusInternalServerError)
|
||
}
|
||
|
||
// Create slice for files in folders.
|
||
filesInFolder := make([]string, 0)
|
||
|
||
// Add all files to the slice with files.
|
||
for _, entry := range entries {
|
||
filesInFolder = append(filesInFolder, entry.Name())
|
||
}
|
||
|
||
// Add resolution and all files to the response map.
|
||
files[resolution] = filesInFolder
|
||
}
|
||
|
||
// Prepare the Response.
|
||
vodsRes := ListVodsResponse{
|
||
EstimatedCount: len(files),
|
||
Files: files,
|
||
}
|
||
|
||
// Write header and code response.
|
||
return c.JSON(vodsRes)
|
||
}
|
||
|
||
// SingleVodsHandler returns a specific file in archive for a specific resolution and VOD location.
|
||
//
|
||
// This method allows to get a single VOD file.
|
||
func SingleVodsHandler(c *fiber.Ctx) error {
|
||
// Read camera id, res, filename.
|
||
id := c.Params("id")
|
||
res := c.Params("res")
|
||
file := c.Params("file")
|
||
|
||
// Calculate file size in bytes.
|
||
FileBytes, err := storage.FileBytes(id, res, file)
|
||
if err != nil {
|
||
return c.SendStatus(fiber.StatusBadRequest)
|
||
}
|
||
|
||
dur, tracks, err := storage.GetDurAndTracks(id, res, file)
|
||
if err != nil {
|
||
return c.SendStatus(fiber.StatusInternalServerError)
|
||
}
|
||
|
||
media := MediaSingleVodsResponse{
|
||
Tracks: tracks,
|
||
Duration: dur,
|
||
Provider: "Insit",
|
||
Title: id,
|
||
}
|
||
|
||
// Prepare the Response.
|
||
vodsRes := SingleVodsResponse{
|
||
Name: file,
|
||
Prefix: id,
|
||
Url: c.OriginalURL(),
|
||
Folder: res,
|
||
Bytes: FileBytes,
|
||
MediaInfo: media,
|
||
}
|
||
|
||
// Write header and code response.
|
||
return c.JSON(vodsRes)
|
||
}
|
||
|
||
// DelSingleVodsHandler deletes a VOD file by its name.
|
||
//
|
||
// This method deletes a VOD file by its name.
|
||
func DelSingleVodsHandler(c *fiber.Ctx) error {
|
||
// Read camera id, res, filename.
|
||
id := c.Params("id")
|
||
res := c.Params("res")
|
||
file := c.Params("file")
|
||
|
||
if err := os.Remove(fmt.Sprintf("%s/%s/%s/%s", config.DirData, id, res, file)); err != nil {
|
||
return c.SendStatus(fiber.StatusNotFound)
|
||
}
|
||
|
||
return c.SendStatus(fiber.StatusNoContent)
|
||
}
|
||
|
||
// //////////////////////////////////////////////////////////////////////////
|
||
// //////////////////////////////////////////////////////////////////////////
|
||
const (
|
||
ContextKeyUser = "user"
|
||
)
|
||
|
||
func Auth() {
|
||
app := fiber.New()
|
||
|
||
authStorage := &AuthStorage{map[string]User{}}
|
||
authHandler := &AuthHandler{Storage: authStorage}
|
||
userHandler := &UserHandler{Storage: authStorage}
|
||
|
||
// Группа обработчиков, которые доступны неавторизованным пользователям
|
||
publicGroup := app.Group("")
|
||
publicGroup.Post("/register", authHandler.Register)
|
||
publicGroup.Post("/login", authHandler.Login)
|
||
|
||
// Группа обработчиков, которые требуют авторизации
|
||
authorizedGroup := app.Group("")
|
||
authorizedGroup.Use(jwtware.New(jwtware.Config{
|
||
SigningKey: jwtware.SigningKey{
|
||
Key: JwtSecretKey,
|
||
},
|
||
ContextKey: ContextKeyUser,
|
||
}))
|
||
authorizedGroup.Get("/profile", userHandler.Profile)
|
||
|
||
logrus.Fatal(app.Listen(":8100"))
|
||
}
|
||
|
||
type (
|
||
// Обработчик HTTP-запросов на регистрацию и аутентификацию пользователей
|
||
AuthHandler struct {
|
||
Storage *AuthStorage
|
||
}
|
||
|
||
// Хранилище зарегистрированных пользователей
|
||
// Данные хранятся в оперативной памяти
|
||
AuthStorage struct {
|
||
Users map[string]User
|
||
}
|
||
|
||
// Структура данных с информацией о пользователе
|
||
User struct {
|
||
Email string
|
||
Name string
|
||
password string
|
||
}
|
||
)
|
||
|
||
// Структура HTTP-запроса на регистрацию пользователя
|
||
type RegisterRequest struct {
|
||
Email string `json:"email"`
|
||
Name string `json:"name"`
|
||
Password string `json:"password"`
|
||
}
|
||
|
||
// Обработчик HTTP-запросов на регистрацию пользователя
|
||
func (h *AuthHandler) Register(c *fiber.Ctx) error {
|
||
regReq := RegisterRequest{}
|
||
if err := c.BodyParser(®Req); err != nil {
|
||
return fmt.Errorf("body parser: %w", err)
|
||
}
|
||
|
||
// Проверяем, что пользователь с таким email еще не зарегистрирован
|
||
if _, exists := h.Storage.Users[regReq.Email]; exists {
|
||
return errors.New("the user already exists")
|
||
}
|
||
|
||
// Сохраняем в память нового зарегистрированного пользователя
|
||
h.Storage.Users[regReq.Email] = User{
|
||
Email: regReq.Email,
|
||
Name: regReq.Name,
|
||
password: regReq.Password,
|
||
}
|
||
|
||
return c.SendStatus(fiber.StatusCreated)
|
||
}
|
||
|
||
// Структура HTTP-запроса на вход в аккаунт
|
||
type LoginRequest struct {
|
||
Email string `json:"email"`
|
||
Password string `json:"password"`
|
||
}
|
||
|
||
// Структура HTTP-ответа на вход в аккаунт
|
||
// В ответе содержится JWT-токен авторизованного пользователя
|
||
type LoginResponse struct {
|
||
AccessToken string `json:"access_token"`
|
||
}
|
||
|
||
var (
|
||
errBadCredentials = errors.New("email or password is incorrect")
|
||
)
|
||
|
||
// Секретный ключ для подписи JWT-токена
|
||
// Необходимо хранить в безопасном месте
|
||
var JwtSecretKey = []byte("very-secret-key")
|
||
|
||
// Обработчик HTTP-запросов на вход в аккаунт
|
||
func (h *AuthHandler) Login(c *fiber.Ctx) error {
|
||
regReq := LoginRequest{}
|
||
if err := c.BodyParser(®Req); err != nil {
|
||
return fmt.Errorf("body parser: %w", err)
|
||
}
|
||
|
||
// Ищем пользователя в памяти приложения по электронной почте
|
||
user, exists := h.Storage.Users[regReq.Email]
|
||
// Если пользователь не найден, возвращаем ошибку
|
||
if !exists {
|
||
return errBadCredentials
|
||
}
|
||
// Если пользователь найден, но у него другой пароль, возвращаем ошибку
|
||
if user.password != regReq.Password {
|
||
return errBadCredentials
|
||
}
|
||
|
||
// Генерируем JWT-токен для пользователя,
|
||
// который он будет использовать в будущих HTTP-запросах
|
||
|
||
// Генерируем полезные данные, которые будут храниться в токене
|
||
payload := jwt.MapClaims{
|
||
"sub": user.Email,
|
||
"exp": time.Now().Add(time.Hour * 72).Unix(),
|
||
}
|
||
|
||
// Создаем новый JWT-токен и подписываем его по алгоритму HS256
|
||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, payload)
|
||
|
||
t, err := token.SignedString(JwtSecretKey)
|
||
if err != nil {
|
||
logrus.WithError(err).Error("JWT token signing")
|
||
return c.SendStatus(fiber.StatusInternalServerError)
|
||
}
|
||
|
||
return c.JSON(LoginResponse{AccessToken: t})
|
||
}
|
||
|
||
// Обработчик HTTP-запросов, которые связаны с пользователем
|
||
type UserHandler struct {
|
||
Storage *AuthStorage
|
||
}
|
||
|
||
// Структура HTTP-ответа с информацией о пользователе
|
||
type ProfileResponse struct {
|
||
Email string `json:"email"`
|
||
Name string `json:"name"`
|
||
}
|
||
|
||
func jwtPayloadFromRequest(c *fiber.Ctx) (jwt.MapClaims, bool) {
|
||
jwtToken, ok := c.Context().Value(ContextKeyUser).(*jwt.Token)
|
||
if !ok {
|
||
logrus.WithFields(logrus.Fields{
|
||
"jwt_token_context_value": c.Context().Value(ContextKeyUser),
|
||
}).Error("wrong type of JWT token in context")
|
||
return nil, false
|
||
}
|
||
|
||
payload, ok := jwtToken.Claims.(jwt.MapClaims)
|
||
if !ok {
|
||
logrus.WithFields(logrus.Fields{
|
||
"jwt_token_claims": jwtToken.Claims,
|
||
}).Error("wrong type of JWT token claims")
|
||
return nil, false
|
||
}
|
||
|
||
return payload, true
|
||
}
|
||
|
||
// Обработчик HTTP-запросов на получение информации о пользователе
|
||
func (h *UserHandler) Profile(c *fiber.Ctx) error {
|
||
jwtPayload, ok := jwtPayloadFromRequest(c)
|
||
if !ok {
|
||
return c.SendStatus(fiber.StatusUnauthorized)
|
||
}
|
||
|
||
userInfo, ok := h.Storage.Users[jwtPayload["sub"].(string)]
|
||
if !ok {
|
||
return errors.New("user not found")
|
||
}
|
||
|
||
return c.JSON(ProfileResponse{
|
||
Email: userInfo.Email,
|
||
Name: userInfo.Name,
|
||
})
|
||
}
|