Signed-off-by: Mirror <voice@magicalmirro.red>
This commit is contained in:
Mirror 2022-06-16 12:07:46 +03:00
commit 45d48b78ea
Signed by: voice
GPG Key ID: C2DCB75FD46475D2
10 changed files with 653 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
videopage
web
config.yml

206
admin.go Normal file
View File

@ -0,0 +1,206 @@
package main
import (
"crypto/md5"
"crypto/subtle"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"log"
"net/http"
"os"
"path"
"strings"
"time"
)
type AdminApi struct {
mux *http.ServeMux
}
func NewAdminApi() (a *AdminApi) {
a = &AdminApi{}
a.mux = http.NewServeMux()
a.mux.HandleFunc("/", a.adminHandler)
a.mux.HandleFunc("/delete", a.deleteVideo)
a.mux.HandleFunc("/upload", a.uploadVideo)
a.mux.HandleFunc("/rename", a.renameVideo)
return a
}
func (a *AdminApi) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if a.checkAuth(w, r) {
a.mux.ServeHTTP(w, r)
}
}
func (a *AdminApi) checkAuth(w http.ResponseWriter, r *http.Request) (success bool) {
success = false
user, password, ok := r.BasicAuth()
if subtle.ConstantTimeCompare([]byte(user), []byte(config.AdminUser)) == 1 &&
subtle.ConstantTimeCompare([]byte(password), []byte(config.AdminPassword)) == 1 {
success = true
}
if !success {
if !ok {
time.Sleep(3 * time.Second)
}
w.Header().Set("WWW-Authenticate", fmt.Sprintf("Basic realm=\"%s\"", config.ApplicationName))
http.Error(w, "Unauthorized", http.StatusUnauthorized)
}
return success
}
func (a *AdminApi) adminHandler(w http.ResponseWriter, r *http.Request) {
var v struct {
ApplicationName string
Videos []Video
}
v.ApplicationName = config.ApplicationName
dataDir := path.Join(config.DataDirectory, "video")
dir, err := os.ReadDir(dataDir)
if err != nil {
log.Printf("admin: can't read data directory: %s", err.Error())
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
for _, entry := range dir {
if entry.Type().IsRegular() && strings.HasSuffix(entry.Name(), ".json") && entry.Name() != "index.json" {
video, err := readVideoInfo(path.Join(dataDir, entry.Name()))
if err != nil {
log.Printf("admin: can't read video info: %s", err.Error())
continue
}
v.Videos = append(v.Videos, video)
}
}
err = executeTemplate(w, "admin.html", &v)
if err != nil {
log.Printf("Can't execute template \"admin.html\": %s", err.Error())
return
}
}
func (a *AdminApi) deleteVideo(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
err := r.ParseForm()
referer := r.Header.Get("Referer")
if err != nil {
log.Printf("can't parse form: %s", err.Error())
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
ID := r.Form.Get("fileid")
ID = path.Clean(path.Base(ID))
rawInfo, err := ioutil.ReadFile(path.Join(config.DataDirectory, "video", ID+".json"))
if err != nil {
log.Printf("can't read info: %s", err.Error())
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
var info Video
err = json.Unmarshal(rawInfo, &info)
if err != nil {
log.Printf("can't read info: %s", err.Error())
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
err = os.Remove(path.Join(config.DataDirectory, "video", info.Name))
if err != nil {
log.Printf("can't remove video: %s", err.Error())
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
err = os.Remove(path.Join(config.DataDirectory, "video", ID+".json"))
if err != nil {
log.Printf("can't remove info: %s", err.Error())
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, referer, http.StatusMovedPermanently)
}
func (a *AdminApi) uploadVideo(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
err := r.ParseForm()
if err != nil {
log.Printf("can't parse form: %s", err.Error())
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
referer := r.Header.Get("Referer")
reader, err := r.MultipartReader()
if err != nil {
log.Printf("can't read multipart: %s", err.Error())
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
for {
header, err := reader.NextPart()
if err == io.EOF {
break
}
defer header.Close()
if header.FormName() != "file" {
break
}
filename := header.FileName()
if len(filename) == 0 {
http.Error(w, "Filename are empty", http.StatusBadRequest)
return
}
tempFile, err := ioutil.TempFile(path.Join(config.DataDirectory, "tmp"), "upload-")
if err != nil {
log.Printf("can't create temp file: %s", err.Error())
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
defer tempFile.Close()
hashbucket := md5.New()
fileWriter := io.MultiWriter(tempFile, hashbucket)
written, err := io.Copy(fileWriter, header)
if err != nil {
log.Printf("can't write temp file: %s", err.Error())
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
tmpName := path.Base(tempFile.Name())
err = tempFile.Close()
if err != nil {
log.Printf("can't close file: %s", err.Error())
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
var video Video
video.DisplayName = path.Base(path.Clean(filename))
video.ID = fmt.Sprintf("%x", hashbucket.Sum(nil))
video.Name = fmt.Sprintf("%s%s", video.ID, path.Ext(filename))
video.Size = written
err = os.Rename(path.Join(config.DataDirectory, "tmp", tmpName), path.Join(config.DataDirectory, "video", video.Name))
if err != nil {
log.Printf("can't rename file: %s", err.Error())
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
infoRaw, err := json.Marshal(&video)
if err != nil {
log.Printf("can't marshal info: %s", err.Error())
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
err = ioutil.WriteFile(path.Join(config.DataDirectory, "video", video.ID+".json"), infoRaw, os.ModePerm)
if err != nil {
log.Printf("can't write info: %s", err.Error())
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
}
http.Redirect(w, r, referer, http.StatusMovedPermanently)
}
func (a *AdminApi) renameVideo(w http.ResponseWriter, r *http.Request) {}

42
config.go Normal file
View File

@ -0,0 +1,42 @@
package main
import (
"fmt"
"io/ioutil"
"gopkg.in/yaml.v2"
)
type Config struct {
ApplicationName string `yaml:"application_name"`
ApplicationPrefix string `yaml:"application_prefix"`
DataDirectory string `yaml:"data_directory"`
ListenAddress string `yaml:"listen_address"`
AdminUser string `yaml:"admin_user"`
AdminPassword string `yaml:"admin_password"`
Generation int `yaml:"generation"`
}
func (c *Config) ParseFile(path string) (err error) {
configRaw, err := ioutil.ReadFile(path)
if err != nil {
err = fmt.Errorf("can't load configuration: %s", err.Error())
return
}
err = yaml.UnmarshalStrict(configRaw, c)
if err != nil {
err = fmt.Errorf("can't parse configuration: %s", err.Error())
return
}
return
}
func NewConfig() *Config {
return &Config{}
}
func NewConfigParseFile(path string) (c *Config, err error) {
c = NewConfig()
err = c.ParseFile(path)
return
}

5
go.mod Normal file
View File

@ -0,0 +1,5 @@
module videopage
go 1.18
require gopkg.in/yaml.v2 v2.4.0 // indirect

3
go.sum Normal file
View File

@ -0,0 +1,3 @@
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=

18
logger.go Normal file
View File

@ -0,0 +1,18 @@
package main
import (
"log"
"net/http"
)
type Logger struct {
h http.Handler
}
func NewLogger(h http.Handler) (l *Logger) {
return &Logger{h: h}
}
func (l *Logger) ServeHTTP(w http.ResponseWriter, r *http.Request) {
log.Printf("%s %s %s%s", r.RemoteAddr, r.Method, r.Host, r.URL)
l.h.ServeHTTP(w, r)
}

195
main.go Normal file
View File

@ -0,0 +1,195 @@
package main
import (
"bufio"
"bytes"
"crypto/md5"
"embed"
"encoding/json"
"flag"
"fmt"
"html/template"
"io"
"io/ioutil"
"log"
"net/http"
"os"
"path"
"strings"
)
var (
templates *template.Template
//go:embed templates
fs embed.FS
config *Config
)
func main() {
var configPath string
var rescan bool
flag.StringVar(&configPath, "config", "config.yaml", "Path to the configuration file")
flag.BoolVar(&rescan, "rescan", false, "Rescan the data direcory")
flag.Parse()
var err error
config, err = NewConfigParseFile(configPath)
if err != nil {
log.Fatal(err.Error())
}
if rescan {
rescanFiles()
return
}
{
entry, err := os.Stat(path.Join(config.DataDirectory, "tmp"))
if os.IsNotExist(err) {
err = os.Mkdir(path.Join(config.DataDirectory, "tmp"), os.ModeDir)
if err != nil {
log.Printf("FATAL: can't create tmp directory: %s", err.Error())
os.Exit(-1)
}
}
if err != nil {
log.Printf("FATAL: can't stat tmp directory: %s", err.Error())
os.Exit(-2)
}
if !entry.IsDir() {
log.Printf("FATAL: tmp path isn't directory")
os.Exit(-3)
}
}
templates = template.Must(template.ParseFS(fs, "templates/*"))
log.Printf("Loaded templates: %s", templates.DefinedTemplates())
videoFS := http.FileServer(http.Dir(config.DataDirectory))
adminMux := NewAdminApi()
mux := http.NewServeMux()
mux.HandleFunc("/", indexHandler)
mux.HandleFunc("/video/index.json", videoIndexHandler)
mux.Handle("/video/", videoFS)
mux.Handle("/admin/", http.StripPrefix("/admin", adminMux))
if len(config.ApplicationPrefix) > 0 {
http.ListenAndServe(config.ListenAddress, http.StripPrefix(config.ApplicationPrefix, NewLogger(mux)))
} else {
http.ListenAndServe(config.ListenAddress, NewLogger(mux))
}
}
func indexHandler(w http.ResponseWriter, r *http.Request) {
var v struct {
ApplicationName string
}
v.ApplicationName = config.ApplicationName
err := executeTemplate(w, "index.html", &v)
if err != nil {
log.Printf("Error executing temlate \"index.html\": %s", err.Error())
}
}
func videoIndexHandler(w http.ResponseWriter, r *http.Request) {
var index VideoIndex
index.Success = true
index.Generation = config.Generation
dir, err := os.ReadDir(path.Join(config.DataDirectory, "video"))
if err != nil {
log.Printf("can't red video directory: %s", err.Error())
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
for _, entry := range dir {
if entry.Type().IsRegular() && path.Ext(entry.Name()) == ".json" && entry.Name() != "index.json" {
var v Video
rawInfo, err := ioutil.ReadFile(path.Join(config.DataDirectory, "video", entry.Name()))
if err != nil {
log.Printf("can't read info for %s: %s", entry.Name(), err.Error())
continue
}
err = json.Unmarshal(rawInfo, &v)
if err != nil {
log.Printf("can't read info for %s: %s", entry.Name(), err.Error())
continue
}
index.Videos = append(index.Videos, v)
index.Count++
}
}
indexRaw, err := json.Marshal(index)
if err != nil {
log.Printf("can't marshal index: %s", err.Error())
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
w.Write(indexRaw)
}
func executeTemplate(w http.ResponseWriter, name string, data interface{}) (err error) {
var buf bytes.Buffer
bufferRW := bufio.NewReadWriter(bufio.NewReader(&buf), bufio.NewWriter(&buf))
err = templates.ExecuteTemplate(bufferRW, name, data)
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
bufferRW.Flush()
w.WriteHeader(http.StatusOK)
io.Copy(w, bufferRW)
return
}
func rescanFiles() {
var index VideoIndex
index.Generation = config.Generation
index.Success = true
dirPath := path.Join(config.DataDirectory, "video")
dir, err := os.ReadDir(dirPath)
if err != nil {
log.Printf("can't read data directory: %s", err.Error())
}
for _, entry := range dir {
if entry.Type().IsRegular() {
if !strings.HasSuffix(entry.Name(), ".json") {
var v Video
file, err := os.Open(path.Join(dirPath, entry.Name()))
if err != nil {
log.Printf("can't read info file: %s", err.Error())
}
v.Name = entry.Name()
hashsum := md5.New()
_, err = io.Copy(hashsum, file)
if err != nil {
log.Printf("can't read info file: %s", err.Error())
}
hashstring := fmt.Sprintf("%x", hashsum.Sum(nil))
v.ID = hashstring
ext := path.Ext(entry.Name())
err = os.Rename(path.Join(dirPath, entry.Name()), path.Join(dirPath, string(hashstring)+ext))
if err != nil {
log.Printf("can't move file: %s", err.Error())
}
if info, _ := os.Stat(path.Join(dirPath, v.ID+".json")); info == nil {
rawInfo, err := json.Marshal(&v)
if err != nil {
log.Printf("can't write info: %s", err.Error())
}
err = ioutil.WriteFile(path.Join(dirPath, v.ID+".json"), rawInfo, os.ModePerm)
if err != nil {
log.Printf("can't write info: %s", err.Error())
}
}
index.Videos = append(index.Videos, v)
index.Count++
}
}
}
rawIndex, err := json.Marshal(&index)
if err != nil {
log.Fatalf("can't create index: %s", err)
}
err = ioutil.WriteFile(path.Join(dirPath, "index.json"), rawIndex, os.ModePerm)
if err != nil {
log.Fatalf("can't create index: %s", err)
}
}

41
templates/admin.html Normal file
View File

@ -0,0 +1,41 @@
{{define "admin.html"}}
<!DOCTYPE html>
<html>
<head>
<title>{{.ApplicationName}} | Admin page</title>
<style>
body {
background-color: black;
color: antiquewhite;
}
</style>
</head>
<body>
<div class="playlist">
<p>Current playlist</p>
<table>
<thead>
<tr>
<td></td>
<td>name</td>
<td>size</td>
<td>preview</td>
</tr>
</thead>
<tbody>
{{range .Videos}}
<tr>
<td><form action="delete" method="post"><input id="fileid" name="fileid" type="hidden" value="{{.ID}}"><input value="Delete" type="submit"></form></td>
<td>{{html .DisplayName}}</td>
<td>{{html .Size}}</td>
</tr>
{{end}}
</tbody>
<form action="upload" method="post" enctype="multipart/form-data">
<p>Add new: <input id="file" type="file" name="file" multiple> <input type="submit" ></p>
</form>
</table>
</div>
</body>
</html>
{{end}}

100
templates/index.html Normal file
View File

@ -0,0 +1,100 @@
{{define "index.html"}}
<!DOCTYPE html>
<html>
<head>
<title>{{.ApplicationName}}</title>
<style>
body {
background-color: black;
}
.error {
color: red;
}
.player-container .player {
object-fit: cover;
width: 100vw;
height: 100vh;
position: fixed;
top: 0;
left: 0;
}
</style>
<script type="text/javascript">
var player
var generation = 0
var current_track = -1
var total_tracks = -1
var track_list = []
var last_known_time = -1
options = {
timeout: 5000
}
function load() {
fetch("video/index.json", {timeout: 5000})
.then(response => response.json())
.then(data => {
if (!data.success) {
console.log("failed to fetch index.json")
return false
}
if (typeof data.generation =="number") {
if (generation == 0) {
generation = data.generation
}
if (data.generation > generation) {
location.reload()
}
}
total_tracks = data.count
track_list = data.videos
if (current_track == -1) {
current_track = 0
player.src = "video/" + track_list[current_track].name
}
})
return true
}
function videoEnd(){
if (total_tracks == -1) {
return
}
if (player.ended) {
nextVideo()
}
}
function nextVideo(){
current_track++
if (current_track > total_tracks-1) {
current_track = 0
}
player.src = "video/" + track_list[current_track].name
}
function checkStall(){
if (last_known_time == -1) {
last_known_time = player.currentTime
return
}
if (last_known_time == player.currentTime) {
nextVideo()
}
}
window.onload = function() {
player = document.getElementById("player")
player.autoplay = true
player.controls = true
load()
setInterval(videoEnd, 1000)
setInterval(load, 60000)
setInterval(checkStall, 30000)
}
</script>
</head>
<body>
<noscript><h1 class="error">JavaScript is required for this page but not available</h1></noscript>
<div id="player-container" class="player-container">
<video id="player" class="player"></video>
</div>
</body>
</html>
{{end}}

39
video.go Normal file
View File

@ -0,0 +1,39 @@
package main
import (
"encoding/json"
"fmt"
"io/ioutil"
"strings"
)
type Video struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
ID string `json:"id"`
Size int64 `json:"size"`
}
type VideoIndex struct {
Success bool `json:"success"`
Generation int `json:"generation"`
Count int `json:"count"`
Videos []Video `json:"videos"`
}
func readVideoInfo(filepath string) (video Video, err error) {
if !strings.HasSuffix(filepath, ".json") {
filepath = filepath + ".json"
}
infoRaw, err := ioutil.ReadFile(filepath)
if err != nil {
err = fmt.Errorf("can't read infofile \"%s\": %s", filepath, err.Error())
return
}
err = json.Unmarshal(infoRaw, &video)
if err != nil {
err = fmt.Errorf("can't read infofile \"%s\": %s", filepath, err.Error())
return
}
return
}