Move to git.themmonk.com

This commit is contained in:
the-m-monk
2025-12-29 22:05:31 +10:00
parent b63049e91c
commit f4944ab3b3
24 changed files with 701 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
/opal
#used for testing streaming/playback
*.mp4
*.mk4

2
api.txt Normal file
View File

@@ -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

19
cmd/opal/opal.go Normal file
View File

@@ -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()
}

View File

@@ -0,0 +1,4 @@
name="Library 1"
#do not put spaces or weird chars in path name, numbers allowed
path_name="lib1"
path="./lib1"

View File

@@ -0,0 +1,4 @@
name="Library 2"
#do not put spaces or weird chars in path name, numbers allowed
path_name="lib2"
path="./lib2"

2
config/server.cfg Normal file
View File

@@ -0,0 +1,2 @@
address=127.0.0.1
port=8096

5
go.mod Normal file
View File

@@ -0,0 +1,5 @@
module opal
go 1.25.3
require github.com/google/uuid v1.6.0 // indirect

2
go.sum Normal file
View File

@@ -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=

161
internal/config/config.go Normal file
View File

@@ -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"
}

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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))
}

22
todo.txt Normal file
View File

@@ -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)

3
web/index.html Normal file
View File

@@ -0,0 +1,3 @@
<script>
window.location.replace('/web/login');
</script>

0
web/js/api/auth.js Normal file
View File

22
web/js/api/libraries.js Normal file
View File

@@ -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";
}
}

69
web/js/auth.js Normal file
View File

@@ -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.');
}
}

49
web/js/libraries.js Normal file
View File

@@ -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();

View File

@@ -0,0 +1,3 @@
import * as lib from '/web/js/api/libraries.js';
console.log(await lib.ls("lib1", "/"))

24
web/libraries Normal file
View File

@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Opal Media Server</title>
</head>
<body>
<h2>Libraries</h2>
<div id="library-entry-container"></div>
<!-- begin templates -->
<template id="library-template">
<a class="library-viewer-href"></a>
</template>
<!-- end templates -->
<!-- begin js -->
<script src="/web/js/auth.js" defer></script>
<script src="/web/js/libraries.js" defer></script>
<!-- end js -->
</body>
</html>

18
web/library_viewer Normal file
View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Opal Media Server</title>
</head>
<body>
<h2 id="library-viewer-header">Library viewer:</h2>
<!-- begin templates -->
<!-- end templates -->
<!-- begin js -->
<script type="module" src="/web/js/pages/library_viewer.js"></script>
<!-- end js -->
</body>
</html>

30
web/login Normal file
View File

@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Opal Media Server</title>
</head>
<body>
<h2>Login</h2>
<form id="loginForm">
<label for="username">Username:</label>
<input type="text" id="username" name="username" required><br><br>
<label for="password">Password:</label>
<input type="password" id="password" name="password" required><br><br>
<button type="submit">Login</button>
</form>
<script src="/web/js/auth.js"></script>
<script>
const loginForm = document.getElementById('loginForm');
loginForm.addEventListener('submit', function(event) {
event.preventDefault();
getAccessToken();
});
</script>
</body>
</html>