Phase 3: embed web UI with go:embed, pinned TypeScript toolchain, drop i18n, add /healthz
continuous-integration/drone/push Build encountered an error
continuous-integration/drone/push Build encountered an error
- html/embed.go embeds html/ (pages, css, img, js, video); src/assets.go
serves it, with os.DirFS("html") under -dev. Static assets get an ETag
and Cache-Control: no-cache; HTML pages are still templated (only the
login error message is substituted now).
- Delete the generated src/webUI.go (783 KB base64), src/html-build.go and
cmd/webui-gen; Dockerfile no longer runs a generator.
- Language layer removed: 254 {{.x}} placeholders inlined as English
strings in ts/*.ts and the two auth pages; html/lang/en.json, the
LanguageUI struct and the 'language' setting are gone.
- ts/tsconfig.json + package.json pin typescript 5.9.3; the seven sources
compile (ES2020, global scripts) into one committed html/js/app.js.
Ten unreferenced legacy scripts under html/js/ deleted; all pages load
js/app.js.
- Fix the six type errors that blocked a clean compile, including a real
bug: a missing semicolon in the search shortcut handler made the code
call the result of preventDefault(), so the shortcut threw instead of
focusing the search box.
- /healthz liveness endpoint; Dockerfile healthcheck and README use it.
- Drone: go vet, and a webui-check step that rebuilds the bundle and fails
if the committed app.js is stale.
- README-DEV.md documents build, UI toolchain, -dev, versioning, CI.
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
package src
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
webui "xteve/html"
|
||||
)
|
||||
|
||||
// webFS serves the web interface. It is the embedded copy by default and the
|
||||
// local html/ directory when xTeVe runs with -dev.
|
||||
var webFS fs.FS = webui.FS
|
||||
|
||||
var webETags sync.Map // path -> ETag, only used when not in dev mode
|
||||
|
||||
// initWebFS : selects embedded or on-disk web files.
|
||||
func initWebFS(dev bool) {
|
||||
if dev {
|
||||
webFS = os.DirFS("html")
|
||||
return
|
||||
}
|
||||
webFS = webui.FS
|
||||
}
|
||||
|
||||
// readWebFile : reads a file from the web interface, name relative to html/.
|
||||
func readWebFile(name string) ([]byte, error) {
|
||||
name = strings.TrimPrefix(path.Clean("/"+name), "/")
|
||||
if name == "" || name == "." {
|
||||
return nil, fs.ErrNotExist
|
||||
}
|
||||
return fs.ReadFile(webFS, name)
|
||||
}
|
||||
|
||||
// webFileETag : content hash used for HTTP caching of static web assets.
|
||||
func webFileETag(name string, content []byte) string {
|
||||
if !System.Dev {
|
||||
if v, ok := webETags.Load(name); ok {
|
||||
return v.(string)
|
||||
}
|
||||
}
|
||||
sum := sha256.Sum256(content)
|
||||
etag := `"` + hex.EncodeToString(sum[:16]) + `"`
|
||||
if !System.Dev {
|
||||
webETags.Store(name, etag)
|
||||
}
|
||||
return etag
|
||||
}
|
||||
+1
-4
@@ -153,10 +153,7 @@ func bufferingStream(playlistID, streamingURL, channelName string, w http.Respon
|
||||
|
||||
showInfo(fmt.Sprintf("Streaming Status:Playlist: %s - No new connections available. Tuner = %d", playlist.PlaylistName, playlist.Tuner))
|
||||
|
||||
if value, ok := webUI["html/video/stream-limit.ts"]; ok {
|
||||
|
||||
var content string
|
||||
content = GetHTMLString(value.(string))
|
||||
if content, err := readWebFile("video/stream-limit.ts"); err == nil {
|
||||
|
||||
w.WriteHeader(200)
|
||||
w.Header().Set("Content-type", "video/mpeg")
|
||||
|
||||
+2
-13
@@ -188,16 +188,8 @@ func Init() (err error) {
|
||||
|
||||
System.URLBase = fmt.Sprintf("%s://%s:%s", System.ServerProtocol.WEB, System.IPAddress, Settings.Port)
|
||||
|
||||
// HTML Dateien erstellen, mit dev == true werden die lokalen HTML Dateien verwendet
|
||||
if System.Dev == true {
|
||||
|
||||
HTMLInit("webUI", "src", "html"+string(os.PathSeparator), "src"+string(os.PathSeparator)+"webUI.go")
|
||||
err = BuildGoFile()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
}
|
||||
// Mit dev == true werden die lokalen HTML Dateien verwendet, sonst die eingebetteten
|
||||
initWebFS(System.Dev)
|
||||
|
||||
// DLNA Server starten
|
||||
err = SSDP()
|
||||
@@ -205,9 +197,6 @@ func Init() (err error) {
|
||||
return
|
||||
}
|
||||
|
||||
// HTML Datein laden
|
||||
loadHTMLMap()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
package src
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var htmlFolder string
|
||||
var goFile string
|
||||
var mapName string
|
||||
var packageName string
|
||||
|
||||
var blankMap = make(map[string]any)
|
||||
|
||||
// HTMLInit : Dateipfade festlegen
|
||||
// mapName = Name der zu erstellenden map
|
||||
// htmlFolder: Ordner der HTML Dateien
|
||||
// packageName: Name des package
|
||||
func HTMLInit(name, pkg, folder, file string) {
|
||||
|
||||
htmlFolder = folder
|
||||
goFile = file
|
||||
mapName = name
|
||||
packageName = pkg
|
||||
|
||||
}
|
||||
|
||||
// BuildGoFile : Erstellt das GO Dokument
|
||||
func BuildGoFile() error {
|
||||
|
||||
var err = checkHTMLFile(htmlFolder)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var content string
|
||||
content += `package ` + packageName + "\n\n"
|
||||
content += `var ` + mapName + ` = make(map[string]interface{})` + "\n\n"
|
||||
content += "func loadHTMLMap() {" + "\n\n"
|
||||
|
||||
content += createMapFromFiles(htmlFolder) + "\n"
|
||||
|
||||
content += "}" + "\n\n"
|
||||
writeStringToFile(goFile, content)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetHTMLString : base64 -> string
|
||||
func GetHTMLString(base string) string {
|
||||
content, _ := base64.StdEncoding.DecodeString(base)
|
||||
return string(content)
|
||||
}
|
||||
|
||||
func createMapFromFiles(folder string) string {
|
||||
|
||||
var path = getLocalPath(folder)
|
||||
|
||||
err := filepath.Walk(path, readFilesToMap)
|
||||
if err != nil {
|
||||
checkErr(err)
|
||||
}
|
||||
|
||||
var content strings.Builder
|
||||
|
||||
for key := range blankMap {
|
||||
var newKey = key
|
||||
content.WriteString(` ` + mapName + `["` + newKey + `"` + `] = "` + blankMap[key].(string) + `"` + "\n")
|
||||
}
|
||||
|
||||
return content.String()
|
||||
}
|
||||
|
||||
func readFilesToMap(path string, info os.FileInfo, err error) error {
|
||||
|
||||
if info.IsDir() == false {
|
||||
var base64Str = fileToBase64(getLocalPath(path))
|
||||
blankMap[path] = base64Str
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func fileToBase64(file string) string {
|
||||
|
||||
imgFile, _ := os.Open(file)
|
||||
defer imgFile.Close()
|
||||
|
||||
// create a new buffer base on file size
|
||||
fInfo, _ := imgFile.Stat()
|
||||
var size = fInfo.Size()
|
||||
buf := make([]byte, int64(size))
|
||||
|
||||
// read file content into buffer
|
||||
fReader := bufio.NewReader(imgFile)
|
||||
fReader.Read(buf)
|
||||
|
||||
imgBase64Str := base64.StdEncoding.EncodeToString(buf)
|
||||
|
||||
return imgBase64Str
|
||||
}
|
||||
|
||||
func getLocalPath(filename string) string {
|
||||
|
||||
path, file := filepath.Split(filename)
|
||||
var newPath = filepath.Dir(path)
|
||||
|
||||
var newFileName = newPath + "/" + file
|
||||
|
||||
return newFileName
|
||||
}
|
||||
|
||||
func writeStringToFile(filename, content string) error {
|
||||
|
||||
err := ioutil.WriteFile(getPlatformFile(filename), []byte(content), 0644)
|
||||
if err != nil {
|
||||
checkErr(err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkHTMLFile(filename string) error {
|
||||
|
||||
if _, err := os.Stat(getLocalPath(filename)); os.IsNotExist(err) {
|
||||
fmt.Println(filename)
|
||||
checkErr(err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkErr(err error) {
|
||||
if err != nil {
|
||||
_, file, line, _ := runtime.Caller(1)
|
||||
log.Println("ERROR: [", err, "] in ", file, line)
|
||||
}
|
||||
}
|
||||
@@ -267,7 +267,6 @@ type SettingsStruct struct {
|
||||
FilesUpdate bool `json:"files.update"`
|
||||
Filter map[int64]any `json:"filter"`
|
||||
Key string `json:"key,omitempty"`
|
||||
Language string `json:"language"`
|
||||
LogEntriesRAM int `json:"log.entries.ram"`
|
||||
M3U8AdaptiveBandwidthMBPS int `json:"m3u8.adaptive.bandwidth.mbps"`
|
||||
MappingFirstChannel float64 `json:"mapping.first.channel"`
|
||||
@@ -285,9 +284,3 @@ type SettingsStruct struct {
|
||||
WizardCompleted bool `json:"wizard.completed"`
|
||||
}
|
||||
|
||||
// LanguageUI : Sprache für das WebUI
|
||||
type LanguageUI struct {
|
||||
Login struct {
|
||||
Failed string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,7 +138,6 @@ func loadSettings() (settings SettingsStruct, err error) {
|
||||
defaults["files.update"] = true
|
||||
defaults["filter"] = make(map[string]any)
|
||||
defaults["git.branch"] = System.Branch
|
||||
defaults["language"] = "en"
|
||||
defaults["log.entries.ram"] = 500
|
||||
defaults["mapping.first.channel"] = 1000
|
||||
defaults["xepg.missing.epg.mode"] = "strict"
|
||||
|
||||
File diff suppressed because one or more lines are too long
+56
-75
@@ -38,6 +38,7 @@ func StartWebserver() (err error) {
|
||||
http.HandleFunc("/api/", API)
|
||||
http.HandleFunc("/images/", Images)
|
||||
http.HandleFunc("/data_images/", DataImages)
|
||||
http.HandleFunc("/healthz", Healthz)
|
||||
|
||||
//http.HandleFunc("/auto/", Auto)
|
||||
|
||||
@@ -595,44 +596,36 @@ func WS(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Healthz : Liveness endpoint for container healthchecks and monitoring.
|
||||
// Unauthenticated on purpose; it exposes only the version and whether a scan is running.
|
||||
func Healthz(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var status = map[string]any{
|
||||
"status": "ok",
|
||||
"version": System.Version + "." + System.Build,
|
||||
"scanInProgress": System.ScanInProgress == 1,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(mapToJSON(status)))
|
||||
}
|
||||
|
||||
// Web : Web Server /web/
|
||||
func Web(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var lang = make(map[string]any)
|
||||
var err error
|
||||
var requestFile = strings.TrimPrefix(r.URL.Path, "/web")
|
||||
requestFile = strings.TrimPrefix(requestFile, "/")
|
||||
|
||||
var requestFile = strings.Replace(r.URL.Path, "/web", "html", -1)
|
||||
var content, contentType, file string
|
||||
|
||||
var language LanguageUI
|
||||
// Values substituted into the HTML pages (JS and CSS are served verbatim).
|
||||
var tmpl = map[string]any{"authenticationErr": ""}
|
||||
|
||||
setGlobalDomain(r.Host)
|
||||
|
||||
if System.Dev == true {
|
||||
if requestFile == "" {
|
||||
|
||||
lang, err = loadJSONFileToMap(fmt.Sprintf("html/lang/%s.json", Settings.Language))
|
||||
if err != nil {
|
||||
ShowError(err, 000)
|
||||
}
|
||||
|
||||
} else {
|
||||
|
||||
var languageFile = "html/lang/en.json"
|
||||
|
||||
if value, ok := webUI[languageFile].(string); ok {
|
||||
content = GetHTMLString(value)
|
||||
lang = jsonToMap(content)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
err = json.Unmarshal([]byte(mapToJSON(lang)), &language)
|
||||
if err != nil {
|
||||
ShowError(err, 000)
|
||||
return
|
||||
}
|
||||
|
||||
if getFilenameFromPath(requestFile) == "html" {
|
||||
var file string
|
||||
|
||||
if System.ScanInProgress == 0 {
|
||||
|
||||
@@ -645,16 +638,16 @@ func Web(w http.ResponseWriter, r *http.Request) {
|
||||
switch System.ConfigurationWizard {
|
||||
|
||||
case true:
|
||||
file = requestFile + "configuration.html"
|
||||
file = "configuration.html"
|
||||
Settings.AuthenticationWEB = false
|
||||
|
||||
case false:
|
||||
file = requestFile + "index.html"
|
||||
file = "index.html"
|
||||
|
||||
}
|
||||
|
||||
if System.ScanInProgress == 1 {
|
||||
file = requestFile + "maintenance.html"
|
||||
file = "maintenance.html"
|
||||
}
|
||||
|
||||
switch Settings.AuthenticationWEB {
|
||||
@@ -692,8 +685,8 @@ func Web(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var token, err = authentication.UserAuthentication(username, password)
|
||||
if err != nil {
|
||||
file = requestFile + "login.html"
|
||||
lang["authenticationErr"] = language.Login.Failed
|
||||
file = "login.html"
|
||||
tmpl["authenticationErr"] = "User authentication failed"
|
||||
break
|
||||
}
|
||||
|
||||
@@ -708,17 +701,16 @@ func Web(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
|
||||
case "GET":
|
||||
lang["authenticationErr"] = ""
|
||||
_, token, err := authentication.CheckTheValidityOfTheTokenFromHTTPHeader(w, r)
|
||||
|
||||
if err != nil {
|
||||
file = requestFile + "login.html"
|
||||
file = "login.html"
|
||||
break
|
||||
}
|
||||
|
||||
err = checkAuthorizationLevel(token, "authentication.web")
|
||||
if err != nil {
|
||||
file = requestFile + "login.html"
|
||||
file = "login.html"
|
||||
break
|
||||
}
|
||||
|
||||
@@ -732,58 +724,47 @@ func Web(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if len(allUserData) == 0 && Settings.AuthenticationWEB == true {
|
||||
file = requestFile + "create-first-user.html"
|
||||
file = "create-first-user.html"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
requestFile = file
|
||||
|
||||
if value, ok := webUI[requestFile]; ok {
|
||||
}
|
||||
|
||||
content = GetHTMLString(value.(string))
|
||||
content, err := readWebFile(requestFile)
|
||||
if err != nil {
|
||||
httpStatusError(w, r, 404)
|
||||
return
|
||||
}
|
||||
|
||||
if contentType == "text/plain" {
|
||||
w.Header().Set("Content-Disposition", "attachment; filename="+getFilenameFromPath(requestFile))
|
||||
}
|
||||
var contentType = getContentType(requestFile)
|
||||
|
||||
} else {
|
||||
switch contentType {
|
||||
|
||||
httpStatusError(w, r, 404)
|
||||
case "text/html":
|
||||
content = []byte(parseTemplate(string(content), tmpl))
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
|
||||
case "text/plain":
|
||||
w.Header().Set("Content-Disposition", "attachment; filename="+getFilenameFromPath(requestFile))
|
||||
|
||||
default:
|
||||
// Static asset: let the browser revalidate cheaply with an ETag.
|
||||
var etag = webFileETag(requestFile, content)
|
||||
w.Header().Set("ETag", etag)
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
if r.Header.Get("If-None-Match") == etag {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if value, ok := webUI[requestFile].(string); ok {
|
||||
|
||||
content = GetHTMLString(value)
|
||||
contentType = getContentType(requestFile)
|
||||
|
||||
if contentType == "text/plain" {
|
||||
w.Header().Set("Content-Disposition", "attachment; filename="+getFilenameFromPath(requestFile))
|
||||
}
|
||||
|
||||
} else {
|
||||
httpStatusError(w, r, 404)
|
||||
return
|
||||
}
|
||||
|
||||
contentType = getContentType(requestFile)
|
||||
|
||||
if System.Dev == true {
|
||||
// Lokale Webserver Dateien werden geladen, nur für die Entwicklung
|
||||
content, _ = readStringFromFile(requestFile)
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", contentType)
|
||||
w.WriteHeader(200)
|
||||
|
||||
if contentType == "text/html" || contentType == "application/javascript" {
|
||||
content = parseTemplate(content, lang)
|
||||
}
|
||||
|
||||
w.Write([]byte(content))
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(content)
|
||||
}
|
||||
|
||||
// API : API request /api/
|
||||
|
||||
Reference in New Issue
Block a user