From f4944ab3b374957730e892651dc8126afe55fa58 Mon Sep 17 00:00:00 2001 From: the-m-monk Date: Mon, 29 Dec 2025 22:05:31 +1000 Subject: [PATCH] Move to git.themmonk.com --- .gitignore | 5 + api.txt | 2 + cmd/opal/opal.go | 19 +++ config/libraries/lib1.cfg | 4 + config/libraries/lib2.cfg | 4 + config/server.cfg | 2 + go.mod | 5 + go.sum | 2 + internal/config/config.go | 161 ++++++++++++++++++++++++ internal/httpserver/api/authenticate.go | 46 +++++++ internal/httpserver/api/libraries.go | 139 ++++++++++++++++++++ internal/httpserver/api/misc.go | 33 +++++ internal/httpserver/start.go | 21 ++++ internal/httpserver/webui.go | 18 +++ todo.txt | 22 ++++ web/index.html | 3 + web/js/api/auth.js | 0 web/js/api/libraries.js | 22 ++++ web/js/auth.js | 69 ++++++++++ web/js/libraries.js | 49 ++++++++ web/js/pages/library_viewer.js | 3 + web/libraries | 24 ++++ web/library_viewer | 18 +++ web/login | 30 +++++ 24 files changed, 701 insertions(+) create mode 100644 .gitignore create mode 100644 api.txt create mode 100644 cmd/opal/opal.go create mode 100644 config/libraries/lib1.cfg create mode 100644 config/libraries/lib2.cfg create mode 100644 config/server.cfg create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/httpserver/api/authenticate.go create mode 100644 internal/httpserver/api/libraries.go create mode 100644 internal/httpserver/api/misc.go create mode 100644 internal/httpserver/start.go create mode 100644 internal/httpserver/webui.go create mode 100644 todo.txt create mode 100644 web/index.html create mode 100644 web/js/api/auth.js create mode 100644 web/js/api/libraries.js create mode 100644 web/js/auth.js create mode 100644 web/js/libraries.js create mode 100644 web/js/pages/library_viewer.js create mode 100644 web/libraries create mode 100644 web/library_viewer create mode 100644 web/login diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a426d9f --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/opal + +#used for testing streaming/playback +*.mp4 +*.mk4 \ No newline at end of file diff --git a/api.txt b/api.txt new file mode 100644 index 0000000..0c0ac7d --- /dev/null +++ b/api.txt @@ -0,0 +1,2 @@ +If you actually want me to write some proper docs explaining the api endpoints, submit an issue. +In the meantime, read the code under internal/httpserver/api diff --git a/cmd/opal/opal.go b/cmd/opal/opal.go new file mode 100644 index 0000000..246f9b1 --- /dev/null +++ b/cmd/opal/opal.go @@ -0,0 +1,19 @@ +package main + +import ( + "fmt" + "opal/internal/config" + "opal/internal/httpserver" +) + +// TODO: use linker version numbers for releases +var version_number = "dev" + +func main() { + fmt.Println("Opal media server starting \nVersion:", version_number) + + config.Init() + //usermgmt.Init() + + httpserver.Start() +} diff --git a/config/libraries/lib1.cfg b/config/libraries/lib1.cfg new file mode 100644 index 0000000..d4ef209 --- /dev/null +++ b/config/libraries/lib1.cfg @@ -0,0 +1,4 @@ +name="Library 1" +#do not put spaces or weird chars in path name, numbers allowed +path_name="lib1" +path="./lib1" \ No newline at end of file diff --git a/config/libraries/lib2.cfg b/config/libraries/lib2.cfg new file mode 100644 index 0000000..1b232ad --- /dev/null +++ b/config/libraries/lib2.cfg @@ -0,0 +1,4 @@ +name="Library 2" +#do not put spaces or weird chars in path name, numbers allowed +path_name="lib2" +path="./lib2" \ No newline at end of file diff --git a/config/server.cfg b/config/server.cfg new file mode 100644 index 0000000..6a25178 --- /dev/null +++ b/config/server.cfg @@ -0,0 +1,2 @@ +address=127.0.0.1 +port=8096 \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..57b30ae --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module opal + +go 1.25.3 + +require github.com/google/uuid v1.6.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7790d7c --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..3a800dd --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,161 @@ +package config + +import ( + "fmt" + "os" + "slices" + "strings" +) + +type ConfigNode struct { + Name string + Children []ConfigNode + Properties []string +} + +var configDirectoryPath string = "./config" +var RootConfigNode ConfigNode + +func Init() { + //TODO: use flags module instead + for _, arg := range os.Args[1:] { + if !strings.HasPrefix(arg, "--config-dir=") { + continue + } + + dirStrSplit := strings.Split(arg, "=") + configDirectoryPath = dirStrSplit[len(dirStrSplit)-1] + } + + fmt.Println("Config directory:", configDirectoryPath) + RootConfigNode = LoadConfig(configDirectoryPath, "/") + PrintConfigTree(RootConfigNode) +} + +func LoadConfig(filePath string, name string) ConfigNode { + var retNode ConfigNode + retNode.Name = name + + fileStat, err := os.Lstat(filePath) + if err != nil { + fmt.Printf("Config parsing error: %s\n", err.Error()) + os.Exit(1) + } + + fileMode := fileStat.Mode() + + if !fileMode.IsDir() && (filePath == configDirectoryPath) { + fmt.Printf("Config parsing error: %s is not a directory\n", configDirectoryPath) + os.Exit(1) + } + + if fileMode&os.ModeSymlink != 0 { + fmt.Println("Config parsing error: symlinks currently not supported in config tree") + os.Exit(1) + } + + if fileMode.IsDir() { + retNode.Properties = append(retNode.Properties, "folder=1") + subDirEntries, err := os.ReadDir(filePath) + if err != nil { + fmt.Printf("Config parsing error: %s\n", err.Error()) + os.Exit(1) + } + + for _, subFile := range subDirEntries { + var childName string + if filePath == configDirectoryPath { + childName = "/" + subFile.Name() + } else { + childName = name + "/" + subFile.Name() + } + retNode.Children = append(retNode.Children, LoadConfig(filePath+"/"+subFile.Name(), childName)) + } + return retNode + } + + if !fileMode.IsRegular() { + fmt.Printf("Config parsing error: unable to determine filetype of %s\n", filePath) + os.Exit(1) + } + + fileBytes, err := os.ReadFile(filePath) + if err != nil { + fmt.Printf("Config parsing error: %s\n", err.Error()) + } + + fileString := string(fileBytes) + retNode.Properties = strings.Split(fileString, "\n") + + retNode.Properties = slices.DeleteFunc(retNode.Properties, func(property string) bool { + return strings.HasPrefix(property, "#") + }) + + return retNode +} + +func PrintConfigTree(node ConfigNode) { + fmt.Printf("%s\n", node.Name) + + if NodeIsFolder(&node) { + for _, child := range node.Children { + PrintConfigTree(child) + } + return + } + + for i, property := range node.Properties { + fmt.Printf("%v: %s\n", i, property) + } +} + +func NodeIsFolder(node *ConfigNode) bool { + return slices.Contains(node.Properties, "folder=1") +} + +func FindNode(path string, startNode *ConfigNode) *ConfigNode { + if startNode.Name == path { + return startNode + } + + if !NodeIsFolder(startNode) { + return nil + } + + for _, child := range startNode.Children { + foundNode := FindNode(path, &child) + if foundNode != nil { + return foundNode + } + } + + return nil +} + +func FetchValue(path string, key string, errorOnFail bool) string { + pathNode := FindNode(path, &RootConfigNode) + if pathNode != nil { + //fmt.Printf("Found node %s @ %p\n", path, pathNode) + } else { + fmt.Printf("Config fetching error: unable to find %s in config directory\n", path) + if errorOnFail { + os.Exit(1) + } + return "ERROR_COULD_NOT_FIND_NODE" + } + + for _, property := range pathNode.Properties { + if strings.HasPrefix(property, key+"=") { + val := strings.SplitN(property, "=", 2)[1] + val = strings.TrimSpace(val) + val = strings.Trim(val, `"'`) + return val + } + } + + if errorOnFail { + fmt.Printf("Config fetching error: unable to find %s in %s\n", key, configDirectoryPath+path) + os.Exit(1) + } + return "ERROR_NODE_DOES_NOT_CONTAIN_KEY" +} diff --git a/internal/httpserver/api/authenticate.go b/internal/httpserver/api/authenticate.go new file mode 100644 index 0000000..68d1319 --- /dev/null +++ b/internal/httpserver/api/authenticate.go @@ -0,0 +1,46 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/google/uuid" +) + +func RegisterAuthEndpoints() { + fmt.Println("TODO: change access token format from uuid to a more secure format") + + http.HandleFunc("/auth/user_and_pass", EndpointAuthUserAndPass) + http.HandleFunc("/auth/check_access_token", EndpointAuthCheckAccessToken) +} + +type returnEndpointAuthUserAndPass struct { + AccessToken string +} + +func EndpointAuthUserAndPass(w http.ResponseWriter, r *http.Request) { + fmt.Println("STUB WARNING: EndpointAuthUserAndPass, need to check method and implement authentication") + + //gopls doesnt like this line unless prefixed with var, may be a skill issue on my end + var ret returnEndpointAuthUserAndPass + ret.AccessToken = uuid.New().String() + + retJson, err := json.Marshal(ret) + if err != nil { + fmt.Println("API warning: failed to marshal /auth/user_and_pass response") + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + //w.WriteHeader(http.StatusUnauthorized) + + w.Header().Set("Content-Type", "application/json") + w.Write(retJson) +} + +func EndpointAuthCheckAccessToken(w http.ResponseWriter, r *http.Request) { + fmt.Println("STUB WARNING: EndpointAuthCheckToken, need to check method and implement authentication") + + //w.WriteHeader(http.StatusUnauthorized) +} diff --git a/internal/httpserver/api/libraries.go b/internal/httpserver/api/libraries.go new file mode 100644 index 0000000..2cbb50f --- /dev/null +++ b/internal/httpserver/api/libraries.go @@ -0,0 +1,139 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "opal/internal/config" + "os" + "strings" +) + +func RegisterLibraryEndpoints() { + ProbeLibraries() + + http.HandleFunc("/libraries", EndpointLibraries) + http.HandleFunc("/libraries/", EndpointLibraryList) +} + +type Library struct { + Name string `json:"Name"` + PathName string `json:"PathName"` + Path string `json:"-"` //when marshalled, this will be excluded +} + +var Libraries []Library + +type endpointLibraries struct { + AvailableLibraries []Library +} + +type LibraryListEntry struct { + Name string + Type string //file, movie, show, directory, may add more filetypes in future +} + +type endpointLibraryList struct { + LibraryInfo Library + Listing []LibraryListEntry +} + +func EndpointLibraries(w http.ResponseWriter, r *http.Request) { + var ret endpointLibraries + + ret.AvailableLibraries = Libraries + + retJson, err := json.Marshal(ret) + if err != nil { + fmt.Println("API warning: failed to marshal /libraries response") + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Write(retJson) +} + +// TODO/NON-CRITICAL BUG FIX: web client sends /libraries//{path}/ that then redirects to /libraries/{path}/, should put this comment in the web client code but im too lazy +func EndpointLibraryList(w http.ResponseWriter, r *http.Request) { + url_split := strings.Split(r.URL.Path, "/") + if url_split[0] != "" || url_split[1] != "libraries" { //impossible for this to fail unless there is something really wrong + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + if len(url_split) < 4 { + http.Error(w, "Malformed request", http.StatusBadRequest) + return + } + + library_string := url_split[2] + path := strings.Join(url_split[3:], "/") + + var ret endpointLibraryList + + //TODO: use a map instead + for _, lib := range Libraries { + if lib.PathName != library_string { + continue + } + ret.LibraryInfo = lib + } + + if ret.LibraryInfo.Name == "" { //not found in Libraries because string un-initialised + http.Error(w, "Malformed request", http.StatusBadRequest) //TODO: library not found (should return 404?) + return + } + + //TODO: this is probably a vulnerability + directoryListing, err := os.ReadDir(ret.LibraryInfo.Path + "/" + path) + if err != nil { + fmt.Printf("API warning: potential server error or malicious client: %s\n", err.Error()) + http.Error(w, "Malformed request", http.StatusBadRequest) + return + } + + for _, directoryEntry := range directoryListing { + var entry LibraryListEntry + + fmt.Println(directoryEntry.Name()) + if directoryEntry.IsDir() { + entry.Name = directoryEntry.Name() + entry.Type = "directory" + } + + ret.Listing = append(ret.Listing, entry) + } + + retJson, err := json.Marshal(ret) + if err != nil { + fmt.Printf("API warning: failed to marshal library listing response for %s\n", library_string) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Write(retJson) +} + +func ProbeLibraries() { + libraries_config := config.FindNode("/libraries", &config.RootConfigNode) + if libraries_config == nil { + fmt.Println("Error: unable to find config node for libraries") + os.Exit(1) + } + + for _, child := range libraries_config.Children { + var lib Library + lib.Name = config.FetchValue(child.Name, "name", false) + lib.PathName = config.FetchValue(child.Name, "path_name", false) + lib.Path = config.FetchValue(child.Name, "path", false) + + if strings.HasPrefix("ERROR", lib.Name) || strings.HasPrefix("ERROR", lib.PathName) { + fmt.Printf("Error: %s: bad config\n", child.Name) + os.Exit(1) + } + + Libraries = append(Libraries, lib) + } +} diff --git a/internal/httpserver/api/misc.go b/internal/httpserver/api/misc.go new file mode 100644 index 0000000..9abc753 --- /dev/null +++ b/internal/httpserver/api/misc.go @@ -0,0 +1,33 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" +) + +func RegisterMiscEndpoints() { + http.HandleFunc("/opal", EndpointOpal) +} + +type endpointOpalResponse struct { + ServerApi string + ApiVersion string +} + +// GET /opal +func EndpointOpal(w http.ResponseWriter, r *http.Request) { + var ret endpointOpalResponse + ret.ServerApi = "Opal" + ret.ApiVersion = "0.0.1" + + retJson, err := json.Marshal(ret) + if err != nil { + fmt.Println("API warning: failed to marshal /opal response") + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Write(retJson) +} diff --git a/internal/httpserver/start.go b/internal/httpserver/start.go new file mode 100644 index 0000000..64ee8a1 --- /dev/null +++ b/internal/httpserver/start.go @@ -0,0 +1,21 @@ +package httpserver + +import ( + "net/http" + + "opal/internal/config" + "opal/internal/httpserver/api" +) + +func Start() { + Addr := config.FetchValue("/server.cfg", "address", true) + Port := config.FetchValue("/server.cfg", "port", true) + + api.RegisterMiscEndpoints() + api.RegisterAuthEndpoints() + api.RegisterLibraryEndpoints() + + RegisterWebui() + + http.ListenAndServe(Addr+":"+Port, nil) +} diff --git a/internal/httpserver/webui.go b/internal/httpserver/webui.go new file mode 100644 index 0000000..88a596e --- /dev/null +++ b/internal/httpserver/webui.go @@ -0,0 +1,18 @@ +package httpserver + +import ( + "net/http" +) + +func RegisterWebui() { + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/" { + http.Redirect(w, r, "/web/", http.StatusPermanentRedirect) + return + } + http.NotFound(w, r) + }) + + fs := http.FileServer(http.Dir("web")) + http.Handle("/web/", http.StripPrefix("/web/", fs)) +} diff --git a/todo.txt b/todo.txt new file mode 100644 index 0000000..b7830f6 --- /dev/null +++ b/todo.txt @@ -0,0 +1,22 @@ +TODO: LITERALLY HALF THE API ISNT EVEN AWARE ABOUT AUTHENTICATION, FIX BEFORE RELEASE + +video player, use hls, add direct download at somepoint + +list available files, movies, videos, shows, and folders, for future release, currently just glorified file server +upon the selection of a movie or show, display info and play button, for future release + +add hls streaming and support with ffmpeg (server side) + +add library restrictions based on user permissions and details +add user groups for mass application of permissions (admin, lib1, lib2, etc) +implement auth + +list libraries done, fix with auth + +fix /web/libraries and /web/login to use api js instead of /web/js/login.js and /web/js/libraries.js + +music streaming? +electron app for desktop? +cli tool for easy bulk downloads? + +temporarily use local cache folder, use /var/tmp for fhs compliance later down the line (make user customisable) \ No newline at end of file diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..baaed8e --- /dev/null +++ b/web/index.html @@ -0,0 +1,3 @@ + diff --git a/web/js/api/auth.js b/web/js/api/auth.js new file mode 100644 index 0000000..e69de29 diff --git a/web/js/api/libraries.js b/web/js/api/libraries.js new file mode 100644 index 0000000..581aed1 --- /dev/null +++ b/web/js/api/libraries.js @@ -0,0 +1,22 @@ +import * as auth from '/web/js/api/auth.js'; +import ky from 'https://cdn.jsdelivr.net/npm/ky@1.14.1/+esm' + +export async function ls(library, path) { + try { + const response = await ky.get(`/libraries/${library}/${path}`, { + headers: { + 'Authorization': `Bearer ${auth.ACCESS_TOKEN}`, + }, + }).json(); + + return response; + + } catch (err) { + if (err.response) { + console.error('Unexpected HTTP status code', err.response.status); + } else { + console.error(err); + } + return "err"; + } +} diff --git a/web/js/auth.js b/web/js/auth.js new file mode 100644 index 0000000..59d4e02 --- /dev/null +++ b/web/js/auth.js @@ -0,0 +1,69 @@ +const accessToken = localStorage.getItem('accessToken'); + +async function isAccessTokenOkay() { + try { + const response = await fetch('/auth/check_access_token', { + method: 'GET', + headers: { + 'Authorization': `Bearer ${accessToken}`, + } + }); + + if (response.status === 200) return true; + if (response.status === 401) return false; + + console.error('Error: server sent unexpected HTTP status code', response.status); + return false; + } catch (err) { + console.error('Network or server error:', err); + return false; + } +} + +(async () => { + if (!accessToken) return; + + const valid = await isAccessTokenOkay(); + if (valid && window.location.pathname === '/web/login') { + window.location.href = '/web/libraries'; + } + + if (!valid) { + window.location.href = '/web/login'; + } +})(); + +//used in /web/login, not a general purpose function +async function getAccessToken() { + const username = document.getElementById('username').value; + const password = document.getElementById('password').value; + + try { + const response = await fetch('/auth/user_and_pass', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ username, password }) + }); + + if (response.status === 200) { + const data = await response.json(); + const accessToken = data.AccessToken; + + if (!accessToken) { + alert('Login failed: no token returned'); + return; + } + + localStorage.setItem('accessToken', accessToken); + window.location.href = '/web/libraries'; + } else if (response.status === 401) { + alert('Invalid username or password'); + } else { + alert('Server error, please try again later.'); + } + } catch (err) { + alert('Network error, please try again later.'); + } +} diff --git a/web/js/libraries.js b/web/js/libraries.js new file mode 100644 index 0000000..9776ea3 --- /dev/null +++ b/web/js/libraries.js @@ -0,0 +1,49 @@ +//auth.js handles accessToken +//const accessToken = localStorage.getItem("accessToken"); + +//only to be used by /web/libraries, not general purpose + +async function getAllLibraries() { + try { + const response = await fetch('/libraries', { + method: 'GET', + headers: { + 'Authorization': `Bearer ${accessToken}`, + } + }); + + if (response.status === 401) { + alert("Error: access forbidden, please reauthenticate if error persists"); + return; + } + + if (response.status !== 200) { + console.error('Unexpected HTTP status code', response.status); + return; + } + + const data = await response.json(); + const libraries = data.AvailableLibraries; + + const container = document.getElementById("library-entry-container"); + const template = document.getElementById("library-template"); + + container.innerHTML = ""; + + libraries.forEach(lib => { + const clone = template.content.cloneNode(true); + const link = clone.querySelector(".library-viewer-href"); + + link.textContent = lib.Name; + link.href = `/web/library_viewer?lib=${encodeURIComponent(lib.PathName)}&path=/`; + + container.appendChild(clone); + }); + + } catch (err) { + alert("Network or server error"); + console.error(err); + } +} + +getAllLibraries(); diff --git a/web/js/pages/library_viewer.js b/web/js/pages/library_viewer.js new file mode 100644 index 0000000..d71dc9c --- /dev/null +++ b/web/js/pages/library_viewer.js @@ -0,0 +1,3 @@ +import * as lib from '/web/js/api/libraries.js'; + +console.log(await lib.ls("lib1", "/")) \ No newline at end of file diff --git a/web/libraries b/web/libraries new file mode 100644 index 0000000..cec69dc --- /dev/null +++ b/web/libraries @@ -0,0 +1,24 @@ + + + + + + Opal Media Server + + +

Libraries

+ +
+ + + + + + + + + + + diff --git a/web/library_viewer b/web/library_viewer new file mode 100644 index 0000000..23ec68f --- /dev/null +++ b/web/library_viewer @@ -0,0 +1,18 @@ + + + + + + Opal Media Server + + +

Library viewer:

+ + + + + + + + + diff --git a/web/login b/web/login new file mode 100644 index 0000000..cf61e6a --- /dev/null +++ b/web/login @@ -0,0 +1,30 @@ + + + + + + Opal Media Server + + +

Login

+
+ +

+ + +

+ + +
+ + + + +