commit 45d48b78ea2809efd6f55c671e146d039fb389af Author: Mirror Date: Thu Jun 16 12:07:46 2022 +0300 init Signed-off-by: Mirror diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eca95b9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +videopage +web +config.yml + diff --git a/admin.go b/admin.go new file mode 100644 index 0000000..71a4dba --- /dev/null +++ b/admin.go @@ -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) {} diff --git a/config.go b/config.go new file mode 100644 index 0000000..a1104a4 --- /dev/null +++ b/config.go @@ -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 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ad63a8b --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module videopage + +go 1.18 + +require gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7534661 --- /dev/null +++ b/go.sum @@ -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= diff --git a/logger.go b/logger.go new file mode 100644 index 0000000..d341a16 --- /dev/null +++ b/logger.go @@ -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) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..9d9b807 --- /dev/null +++ b/main.go @@ -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) + } +} diff --git a/templates/admin.html b/templates/admin.html new file mode 100644 index 0000000..dda8cb9 --- /dev/null +++ b/templates/admin.html @@ -0,0 +1,41 @@ +{{define "admin.html"}} + + + + {{.ApplicationName}} | Admin page + + + +
+

Current playlist

+ + + + + + + + + + + {{range .Videos}} + + + + + + {{end}} + + +

Add new:

+ +
namesizepreview
{{html .DisplayName}}{{html .Size}}
+
+ + +{{end}} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..cc862b7 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,100 @@ +{{define "index.html"}} + + + + {{.ApplicationName}} + + + + + +
+ +
+ + +{{end}} \ No newline at end of file diff --git a/video.go b/video.go new file mode 100644 index 0000000..79c34bb --- /dev/null +++ b/video.go @@ -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 +}