package server import ( "fmt" "html/template" "io/fs" "net/http" "os" "path" "path/filepath" "regexp" "sort" "strings" "time" "fes/src/config" "fes/src/ui" "github.com/gomarkdown/markdown" "github.com/gomarkdown/markdown/html" "github.com/gomarkdown/markdown/parser" "github.com/pelletier/go-toml/v2" lua "github.com/yuin/gopher-lua" ) type reqData struct { path string params map[string]string } 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 } func joinBase(base, name string) string { if base == "" { return "/" + name } return base + "/" + name } func basePath(base string) string { if base == "" || base == "." { return "/" } return base } func fixMalformedToml(content string) string { re := regexp.MustCompile(`(?m)^(\s*\w+\s*=\s*)$`) return re.ReplaceAllStringFunc(content, func(match string) string { parts := strings.Split(strings.TrimSpace(match), "=") if len(parts) == 2 && strings.TrimSpace(parts[1]) == "" { key := strings.TrimSpace(parts[0]) return key + " = \"\"" } return match }) } func markdownToHTML(mdText string) string { extensions := parser.CommonExtensions | parser.AutoHeadingIDs | parser.NoEmptyLineBeforeBlock p := parser.NewWithExtensions(extensions) doc := p.Parse([]byte(mdText)) htmlFlags := html.CommonFlags | html.HrefTargetBlank opts := html.RendererOptions{Flags: htmlFlags} renderer := html.NewRenderer(opts) return string(markdown.Render(doc, renderer)) } 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 } func loadLua(luaDir string, entry string, cfg *config.MyConfig, requestData reqData) (string, error) { L := lua.NewState() defer L.Close() coreFiles, err := fs.ReadDir(config.Core, "core") if err == nil { for _, de := range coreFiles { if de.IsDir() || !strings.HasSuffix(de.Name(), ".lua") { continue } path := filepath.Join("core", de.Name()) fileData, err := config.Core.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.Core.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("core.std", "core/std.lua") preloadLuaModule("core.symbol", "core/symbol.lua") preloadLuaModule("core.util", "core/util.lua") L.PreloadModule("fes", func(L *lua.LState) int { mod := L.NewTable() coreModules := []string{} if ents, err := fs.ReadDir(config.Core, "core"); err == nil { for _, e := range ents { if e.IsDir() || !strings.HasSuffix(e.Name(), ".lua") { continue } coreModules = append(coreModules, strings.TrimSuffix(e.Name(), ".lua")) } } for _, modName := range coreModules { path := filepath.Join("core", modName+".lua") fileData, err := config.Core.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 == "builtin" { 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 "", err } if L.GetTop() == 0 { return "", 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 s, nil } return "", nil } if s := L.ToString(-1); s != "" { return s, nil } return "", nil } 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 = 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 } 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) } dir = "." tomlDocument, err := os.ReadFile("Fes.toml") if err != nil { return ui.Error("failed to read Fes.toml", err) } docStr := fixMalformedToml(string(tomlDocument)) var cfg config.MyConfig 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" } notFoundData := ` 404 Not Found

404 Not Found


fes
` if _, err := os.Stat(filepath.Join("www", "404.lua")); err == nil { if nf, err := loadLua(dir, "www/404.lua", &cfg, reqData{}); err == nil { notFoundData = nf } } else if _, err := os.Stat("www/404.html"); err == nil { if buf, err := os.ReadFile("www/404.html"); err == nil { notFoundData = string(buf) } } 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) } } http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { path := r.URL.Path route, ok := routes[path] var status error = nil route = basePath(route) if !ok && strings.HasPrefix(path, "/archive") { fsPath := "." + path info, err := os.Stat(fsPath) if err == nil && info.IsDir() { if htmlStr, err := generateArchiveIndex(fsPath, path); err == nil { w.Write([]byte(htmlStr)) return } } } else if !ok { w.WriteHeader(http.StatusNotFound) w.Write([]byte(notFoundData)) ui.Path(path, config.ErrRouteMiss) return } params := make(map[string]string) for k, val := range r.URL.Query() { if len(val) > 0 { params[k] = val[0] } } req := reqData{ path: path, params: params, } var data []byte if strings.HasSuffix(route, ".lua") { var b string b, err = loadLua(dir, route, &cfg, req) data = []byte(b) } else if strings.HasSuffix(route, ".md") { data, err = os.ReadFile(route) data = []byte(markdownToHTML(string(data))) } else { data, err = os.ReadFile(route) } if err != nil { http.Error(w, fmt.Sprintf("Error loading page: %v", err), http.StatusInternalServerError) status = err return } w.Write(data) ui.Path(path, status) }) fmt.Printf("Server is running on http://localhost:%d\n", *config.Port) return http.ListenAndServe(fmt.Sprintf(":%d", *config.Port), nil) }