package server import ( "fes/modules/config" "fes/modules/ui" "fmt" "github.com/pelletier/go-toml/v2" lua "github.com/yuin/gopher-lua" "html/template" "io/fs" "net/http" "os" "path" "path/filepath" "sort" "strings" "time" ) /* this is the request data we pass over the bus to the application, via the fes.bus interface */ type reqData struct { path string params map[string]string } /* performs relavent handling based on the directory passaed * * Special directories * - www/ <= contains lua routes. * - static/ <= static content accessable at /static/path or /static/dir/path. * - include/ <= globally accessable lua functions, cannot directly access "fes" right now. * - archive/ <= contains user facing files such as archives or dists. * */ func handleDir(entries []os.DirEntry, dir string, routes map[string]string, base string, isStatic bool) error { for _, entry := range entries { path := filepath.Join(dir, entry.Name()) if entry.IsDir() { nextBase := joinBase(base, entry.Name()) subEntries, err := os.ReadDir(path) if err != nil { return fmt.Errorf("failed to read directory %s: %w", path, err) } if err := handleDir(subEntries, path, routes, nextBase, isStatic); err != nil { return err } continue } route := joinBase(base, entry.Name()) if !isStatic && strings.HasSuffix(entry.Name(), ".lua") { name := strings.TrimSuffix(entry.Name(), ".lua") if name == "index" { routes[basePath(base)] = path routes[route] = path continue } route = joinBase(base, name) } else if !isStatic && strings.HasSuffix(entry.Name(), ".md") { name := strings.TrimSuffix(entry.Name(), ".md") if name == "index" { routes[basePath(base)] = path routes[route] = path continue } route = joinBase(base, name) } routes[route] = path } return nil } // TODO(vx-clutch): this should not be a function func loadIncludeModules(L *lua.LState, includeDir string) *lua.LTable { app := L.NewTable() ents, err := os.ReadDir(includeDir) if err != nil { return app } for _, e := range ents { if e.IsDir() || !strings.HasSuffix(e.Name(), ".lua") { continue } base := strings.TrimSuffix(e.Name(), ".lua") path := filepath.Join(includeDir, e.Name()) if _, err := os.Stat(path); err != nil { tbl := L.NewTable() tbl.RawSetString("error", lua.LString(fmt.Sprintf("file not found: %s", path))) app.RawSetString(base, tbl) continue } if err := L.DoFile(path); err != nil { tbl := L.NewTable() tbl.RawSetString("error", lua.LString(err.Error())) app.RawSetString(base, tbl) continue } val := L.Get(-1) L.Pop(1) tbl, ok := val.(*lua.LTable) if !ok || tbl == nil { tbl = L.NewTable() } app.RawSetString(base, tbl) } return app } /* renders the given lua route */ func renderRoute(entry string, cfg *config.AppConfig, requestData reqData) ([]byte, error) { L := lua.NewState() defer L.Close() libFiles, err := fs.ReadDir(config.Lib, "lib") if err == nil { for _, de := range libFiles { if de.IsDir() || !strings.HasSuffix(de.Name(), ".lua") { continue } path := filepath.Join("lib", de.Name()) fileData, err := config.Lib.ReadFile(path) if err != nil { continue } L.DoString(string(fileData)) } } preloadLuaModule := func(name, path string) { L.PreloadModule(name, func(L *lua.LState) int { fileData, err := config.Lib.ReadFile(path) if err != nil { panic(err) } if err := L.DoString(string(fileData)); err != nil { panic(err) } L.Push(L.Get(-1)) return 1 }) } preloadLuaModule("lib.std", "lib/std.lua") preloadLuaModule("lib.symbol", "lib/symbol.lua") preloadLuaModule("lib.util", "lib/util.lua") L.PreloadModule("fes", func(L *lua.LState) int { mod := L.NewTable() libModules := []string{} if ents, err := fs.ReadDir(config.Lib, "lib"); err == nil { for _, e := range ents { if e.IsDir() || !strings.HasSuffix(e.Name(), ".lua") { continue } libModules = append(libModules, strings.TrimSuffix(e.Name(), ".lua")) } } for _, modName := range libModules { path := filepath.Join("lib", modName+".lua") fileData, err := config.Lib.ReadFile(path) if err != nil { continue } if err := L.DoString(string(fileData)); err != nil { continue } val := L.Get(-1) L.Pop(1) tbl, ok := val.(*lua.LTable) if !ok || tbl == nil { tbl = L.NewTable() } if modName == "fes" { tbl.ForEach(func(k, v lua.LValue) { mod.RawSet(k, v) }) } else { mod.RawSetString(modName, tbl) } } mod.RawSetString("app", loadIncludeModules(L, filepath.Join(".", "include"))) if cfg != nil { site := L.NewTable() site.RawSetString("version", lua.LString(cfg.App.Version)) site.RawSetString("name", lua.LString(cfg.App.Name)) authors := L.NewTable() for i, a := range cfg.App.Authors { authors.RawSetInt(i+1, lua.LString(a)) } site.RawSetString("authors", authors) mod.RawSetString("site", site) } bus := L.NewTable() bus.RawSetString("url", lua.LString(requestData.path)) params := L.NewTable() for k, v := range requestData.params { params.RawSetString(k, lua.LString(v)) } bus.RawSetString("params", params) mod.RawSetString("bus", bus) mod.RawSetString("markdown_to_html", L.NewFunction(func(L *lua.LState) int { L.Push(lua.LString(markdownToHTML(L.ToString(1)))) return 1 })) L.Push(mod) return 1 }) if err := L.DoFile(entry); err != nil { return []byte(""), err } if L.GetTop() == 0 { return []byte(""), nil } L.SetGlobal("__fes_result", L.Get(-1)) if err := L.DoString("return tostring(__fes_result)"); err != nil { L.GetGlobal("__fes_result") if s := L.ToString(-1); s != "" { return []byte(s), nil } return []byte(""), nil } if s := L.ToString(-1); s != "" { return []byte(s), nil } return []byte(""), nil } /* this indexes and generate the page for viewing the archive directory */ func generateArchiveIndex(fsPath string, urlPath string) (string, error) { info, err := os.Stat(fsPath) if err != nil { return "", err } if !info.IsDir() { return "", fmt.Errorf("not a directory") } ents, err := os.ReadDir(fsPath) if err != nil { return "", err } type entryInfo struct { name string isDir bool href string size int64 mod time.Time } var list []entryInfo for _, e := range ents { n := e.Name() full := filepath.Join(fsPath, n) st, err := os.Stat(full) if err != nil { continue } isd := st.IsDir() displayName := n if isd { displayName = n + "/" } href := path.Join(urlPath, n) if isd && !strings.HasSuffix(href, "/") { href = href + "/" } size := int64(-1) if !isd { size = st.Size() } list = append(list, entryInfo{name: displayName, isDir: isd, href: href, size: size, mod: st.ModTime()}) } sort.Slice(list, func(i, j int) bool { if list[i].isDir != list[j].isDir { return list[i].isDir } return strings.ToLower(list[i].name) < strings.ToLower(list[j].name) }) urlPath = basePath(strings.TrimPrefix(urlPath, "/archive")) var b strings.Builder b.WriteString("\nIndex of ") b.WriteString(template.HTMLEscapeString(urlPath)) b.WriteString("\n\n

Index of ") b.WriteString(template.HTMLEscapeString(urlPath)) b.WriteString("


")
	if urlPath != "/archive" && urlPath != "/archive/" {
		up := path.Dir(urlPath)
		if up == "." {
			up = "/archive"
		}
		if !strings.HasSuffix(up, "/") {
			up = "/archive" + filepath.Dir(up) + "/"
		}
		b.WriteString(`../` + "\n")
	} else {
		b.WriteString(`../` + "\n")
	}
	nameCol := 50
	for _, ei := range list {
		escapedName := template.HTMLEscapeString(ei.name)
		dateStr := ei.mod.Local().Format("02-Jan-2006 15:04")
		var sizeStr string
		if ei.isDir {
			sizeStr = "-"
		} else {
			sizeStr = fmt.Sprintf("%d", ei.size)
		}
		spaces := 1
		if len(escapedName) < nameCol {
			spaces = nameCol - len(escapedName)
		}
		line := `` + escapedName + `` + strings.Repeat(" ", spaces) + dateStr + strings.Repeat(" ", 19-len(sizeStr)) + sizeStr + "\n"
		b.WriteString(line)
	}
	b.WriteString("

\n") return b.String(), nil } /* generates the data for the not found page. Checks for user-defined source in this order * 404.lua => 404.md => 404.html => default. */ func generateNotFoundData(cfg *config.AppConfig) []byte { notFoundData := []byte(` 404 Not Found

404 Not Found


fes
`) if _, err := os.Stat(filepath.Join("www", "404.lua")); err == nil { if nf, err := renderRoute("www/404.lua", cfg, reqData{}); err == nil { notFoundData = nf } } else if _, err := os.Stat("www/404.md"); err == nil { if buf, err := os.ReadFile("www/404.html"); err == nil { notFoundData = []byte(markdownToHTML(string(buf))) } } else if _, err := os.Stat("www/404.html"); err == nil { if buf, err := os.ReadFile("www/404.html"); err == nil { notFoundData = buf } } return notFoundData } /* helper to load all special directories */ func loadDirs() map[string]string { routes := make(map[string]string) if entries, err := os.ReadDir("www"); err == nil { if err := handleDir(entries, "www", routes, "", false); err != nil { ui.Warning("failed to handle www directory", err) } } if entries, err := os.ReadDir("static"); err == nil { if err := handleDir(entries, "static", routes, "/static", true); err != nil { ui.Warning("failed to handle static directory", err) } } if entries, err := os.ReadDir("archive"); err == nil { if err := handleDir(entries, "archive", routes, "/archive", true); err != nil { ui.Warning("failed to handle archive directory", err) } } return routes } /* helper to parse the Fes.toml and generate config */ func parseConfig() config.AppConfig { tomlDocument, err := os.ReadFile("Fes.toml") if err != nil { ui.Error("failed to read Fes.toml", err) os.Exit(1) } docStr := fixMalformedToml(string(tomlDocument)) var cfg config.AppConfig if err := toml.Unmarshal([]byte(docStr), &cfg); err != nil { ui.Warning("failed to parse Fes.toml", err) cfg.App.Authors = []string{"unknown"} cfg.App.Name = "unknown" cfg.App.Version = "unknown" } return cfg } /* helper to read the archive files */ func readArchive(w http.ResponseWriter, route string) error { fsPath := "." + route if info, err := os.Stat(fsPath); err == nil && info.IsDir() { if page, err := generateArchiveIndex(fsPath, route); err == nil { w.Write([]byte(page)) return nil } else { return err } } return nil } /* start the Fes server */ func Start(dir string) error { if err := os.Chdir(dir); err != nil { return ui.Error(fmt.Sprintf("failed to change directory to %s", dir), err) } cfg := parseConfig() notFoundData := generateNotFoundData(&cfg) routes := loadDirs() http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { route, ok := routes[r.URL.Path] var err error = nil /* defer won't update paramaters unless we do this. */ defer func() { ui.Path(route, err) }() if !ok { err = config.ErrRouteMiss route = r.URL.Path if strings.HasPrefix(route, "/archive") { err = readArchive(w, route) } else { w.WriteHeader(http.StatusNotFound) w.Write([]byte(notFoundData)) } return } params := make(map[string]string) for k, v := range r.URL.Query() { if len(v) > 0 { params[k] = v[0] } } var data []byte if strings.HasSuffix(route, ".lua") { data, err = renderRoute(route, &cfg, reqData{path: r.URL.Path, params: params}) } else if strings.HasSuffix(route, ".md") { data, err = os.ReadFile(route) data = []byte(markdownToHTML(string(data))) data = []byte("\n" + string(data)) } else { data, err = os.ReadFile(route) } if err != nil { http.Error(w, fmt.Sprintf("Error loading page: %v", err), http.StatusInternalServerError) } w.Write(data) }) fmt.Printf("Server is running on http://localhost:%d\n", *config.Port) return http.ListenAndServe(fmt.Sprintf("0.0.0.0:%d", *config.Port), nil) }