fes

Free Easy Site
Log | Files | Refs | README | LICENSE

server.go (12598B)


      1 package server
      2 
      3 import (
      4 	"errors"
      5 	"fes/modules/config"
      6 	"fes/modules/ui"
      7 	"fmt"
      8 	"html/template"
      9 	"io/fs"
     10 	"net/http"
     11 	"os"
     12 	"path"
     13 	"path/filepath"
     14 	"sort"
     15 	"strings"
     16 	"time"
     17 
     18 	"github.com/pelletier/go-toml/v2"
     19 	lua "github.com/yuin/gopher-lua"
     20 )
     21 
     22 /* this is the request data we pass over the bus to the application, via the fes.bus interface */
     23 type reqData struct {
     24 	path   string
     25 	params map[string]string
     26 }
     27 
     28 /* performs relavent handling based on the directory passaed
     29  *
     30  * Special directories
     31  *  - www/     <= contains lua routes.
     32  *  - static/  <= static content accessable at /static/path or /static/dir/path.
     33  *  - include/ <= globally accessable lua functions, cannot directly access "fes" right now.
     34  *  - archive/ <= contains user facing files such as archives or dists.
     35  *
     36  */
     37 func handleDir(entries []os.DirEntry, dir string, routes map[string]string, base string, isStatic bool) error {
     38 	for _, entry := range entries {
     39 		path := filepath.Join(dir, entry.Name())
     40 		if entry.IsDir() {
     41 			nextBase := joinBase(base, entry.Name())
     42 			subEntries, err := os.ReadDir(path)
     43 			if err != nil {
     44 				return fmt.Errorf("failed to read directory %s: %w", path, err)
     45 			}
     46 			if err := handleDir(subEntries, path, routes, nextBase, isStatic); err != nil {
     47 				return err
     48 			}
     49 			continue
     50 		}
     51 		route := joinBase(base, entry.Name())
     52 		if !isStatic && strings.HasSuffix(entry.Name(), ".lua") {
     53 			name := strings.TrimSuffix(entry.Name(), ".lua")
     54 			if name == "index" {
     55 				routes[basePath(base)] = path
     56 				routes[route] = path
     57 				continue
     58 			}
     59 			route = joinBase(base, name)
     60 		} else if !isStatic && strings.HasSuffix(entry.Name(), ".md") {
     61 			name := strings.TrimSuffix(entry.Name(), ".md")
     62 			if name == "index" {
     63 				routes[basePath(base)] = path
     64 				routes[route] = path
     65 				continue
     66 			}
     67 			route = joinBase(base, name)
     68 		}
     69 		routes[route] = path
     70 	}
     71 	return nil
     72 }
     73 
     74 // TODO(vx-clutch): this should not be a function
     75 func loadIncludeModules(L *lua.LState, includeDir string) *lua.LTable {
     76 	app := L.NewTable()
     77 	ents, err := os.ReadDir(includeDir)
     78 	if err != nil {
     79 		return app
     80 	}
     81 	for _, e := range ents {
     82 		if e.IsDir() || !strings.HasSuffix(e.Name(), ".lua") {
     83 			continue
     84 		}
     85 		base := strings.TrimSuffix(e.Name(), ".lua")
     86 		path := filepath.Join(includeDir, e.Name())
     87 		if _, err := os.Stat(path); err != nil {
     88 			tbl := L.NewTable()
     89 			tbl.RawSetString("error", lua.LString(fmt.Sprintf("file not found: %s", path)))
     90 			app.RawSetString(base, tbl)
     91 			continue
     92 		}
     93 		if err := L.DoFile(path); err != nil {
     94 			tbl := L.NewTable()
     95 			tbl.RawSetString("error", lua.LString(err.Error()))
     96 			app.RawSetString(base, tbl)
     97 			continue
     98 		}
     99 		val := L.Get(-1)
    100 		L.Pop(1)
    101 		tbl, ok := val.(*lua.LTable)
    102 		if !ok || tbl == nil {
    103 			tbl = L.NewTable()
    104 		}
    105 		app.RawSetString(base, tbl)
    106 	}
    107 	return app
    108 }
    109 
    110 /* renders the given lua route */
    111 func renderRoute(entry string, cfg *config.AppConfig, requestData reqData) ([]byte, error) {
    112 	L := lua.NewState()
    113 	defer L.Close()
    114 
    115 	libFiles, err := fs.ReadDir(config.Lib, "lib")
    116 	if err == nil {
    117 		for _, de := range libFiles {
    118 			if de.IsDir() || !strings.HasSuffix(de.Name(), ".lua") {
    119 				continue
    120 			}
    121 			path := filepath.Join("lib", de.Name())
    122 			fileData, err := config.Lib.ReadFile(path)
    123 			if err != nil {
    124 				continue
    125 			}
    126 			L.DoString(string(fileData))
    127 		}
    128 	}
    129 
    130 	preloadLuaModule := func(name, path string) {
    131 		L.PreloadModule(name, func(L *lua.LState) int {
    132 			fileData, err := config.Lib.ReadFile(path)
    133 			if err != nil {
    134 				panic(err)
    135 			}
    136 			if err := L.DoString(string(fileData)); err != nil {
    137 				panic(err)
    138 			}
    139 			L.Push(L.Get(-1))
    140 			return 1
    141 		})
    142 	}
    143 
    144 	preloadLuaModule("lib.std", "lib/std.lua")
    145 	preloadLuaModule("lib.symbol", "lib/symbol.lua")
    146 	preloadLuaModule("lib.util", "lib/util.lua")
    147 
    148 	L.PreloadModule("fes", func(L *lua.LState) int {
    149 		mod := L.NewTable()
    150 		libModules := []string{}
    151 		if ents, err := fs.ReadDir(config.Lib, "lib"); err == nil {
    152 			for _, e := range ents {
    153 				if e.IsDir() || !strings.HasSuffix(e.Name(), ".lua") {
    154 					continue
    155 				}
    156 				libModules = append(libModules, strings.TrimSuffix(e.Name(), ".lua"))
    157 			}
    158 		}
    159 		for _, modName := range libModules {
    160 			path := filepath.Join("lib", modName+".lua")
    161 			fileData, err := config.Lib.ReadFile(path)
    162 			if err != nil {
    163 				continue
    164 			}
    165 			if err := L.DoString(string(fileData)); err != nil {
    166 				continue
    167 			}
    168 			val := L.Get(-1)
    169 			L.Pop(1)
    170 			tbl, ok := val.(*lua.LTable)
    171 			if !ok || tbl == nil {
    172 				tbl = L.NewTable()
    173 			}
    174 			if modName == "fes" {
    175 				tbl.ForEach(func(k, v lua.LValue) { mod.RawSet(k, v) })
    176 			} else {
    177 				mod.RawSetString(modName, tbl)
    178 			}
    179 		}
    180 
    181 		mod.RawSetString("app", loadIncludeModules(L, filepath.Join(".", "include")))
    182 
    183 		if cfg != nil {
    184 			site := L.NewTable()
    185 			site.RawSetString("version", lua.LString(cfg.App.Version))
    186 			site.RawSetString("name", lua.LString(cfg.App.Name))
    187 			authors := L.NewTable()
    188 			for i, a := range cfg.App.Authors {
    189 				authors.RawSetInt(i+1, lua.LString(a))
    190 			}
    191 			site.RawSetString("authors", authors)
    192 			mod.RawSetString("site", site)
    193 		}
    194 
    195 		bus := L.NewTable()
    196 		bus.RawSetString("url", lua.LString(requestData.path))
    197 		params := L.NewTable()
    198 		for k, v := range requestData.params {
    199 			params.RawSetString(k, lua.LString(v))
    200 		}
    201 		bus.RawSetString("params", params)
    202 		mod.RawSetString("bus", bus)
    203 
    204 		mod.RawSetString("markdown_to_html", L.NewFunction(func(L *lua.LState) int {
    205 			L.Push(lua.LString(markdownToHTML(L.ToString(1))))
    206 			return 1
    207 		}))
    208 
    209 		L.Push(mod)
    210 		return 1
    211 	})
    212 
    213 	if err := L.DoFile(entry); err != nil {
    214 		return []byte(""), err
    215 	}
    216 
    217 	if L.GetTop() == 0 {
    218 		return []byte(""), nil
    219 	}
    220 
    221 	L.SetGlobal("__fes_result", L.Get(-1))
    222 	if err := L.DoString("return tostring(__fes_result)"); err != nil {
    223 		L.GetGlobal("__fes_result")
    224 		if s := L.ToString(-1); s != "" {
    225 			return []byte(s), nil
    226 		}
    227 		return []byte(""), nil
    228 	}
    229 
    230 	if s := L.ToString(-1); s != "" {
    231 		return []byte(s), nil
    232 	}
    233 	return []byte(""), nil
    234 }
    235 
    236 /* this indexes and generate the page for viewing the archive directory */
    237 func generateArchiveIndex(fsPath string, urlPath string) (string, error) {
    238 	info, err := os.Stat(fsPath)
    239 	if err != nil {
    240 		return "", err
    241 	}
    242 	if !info.IsDir() {
    243 		return "", fmt.Errorf("not a directory")
    244 	}
    245 	ents, err := os.ReadDir(fsPath)
    246 	if err != nil {
    247 		return "", err
    248 	}
    249 	type entryInfo struct {
    250 		name  string
    251 		isDir bool
    252 		href  string
    253 		size  int64
    254 		mod   time.Time
    255 	}
    256 	var list []entryInfo
    257 	for _, e := range ents {
    258 		n := e.Name()
    259 		full := filepath.Join(fsPath, n)
    260 		st, err := os.Stat(full)
    261 		if err != nil {
    262 			continue
    263 		}
    264 		isd := st.IsDir()
    265 		displayName := n
    266 		if isd {
    267 			displayName = n + "/"
    268 		}
    269 		href := path.Join(urlPath, n)
    270 		if isd && !strings.HasSuffix(href, "/") {
    271 			href = href + "/"
    272 		}
    273 		size := int64(-1)
    274 		if !isd {
    275 			size = st.Size()
    276 		}
    277 		list = append(list, entryInfo{name: displayName, isDir: isd, href: href, size: size, mod: st.ModTime()})
    278 	}
    279 	sort.Slice(list, func(i, j int) bool {
    280 		if list[i].isDir != list[j].isDir {
    281 			return list[i].isDir
    282 		}
    283 		return strings.ToLower(list[i].name) < strings.ToLower(list[j].name)
    284 	})
    285 
    286 	urlPath = basePath(strings.TrimPrefix(urlPath, "/archive"))
    287 
    288 	var b strings.Builder
    289 
    290 	b.WriteString("<html>\n<head><title>Index of ")
    291 	b.WriteString(template.HTMLEscapeString(urlPath))
    292 	b.WriteString("</title></head>\n<body>\n<h1>Index of ")
    293 	b.WriteString(template.HTMLEscapeString(urlPath))
    294 	b.WriteString("</h1><hr><pre>")
    295 
    296 	if urlPath != "/" {
    297 		b.WriteString(
    298 			`<a href="/archive` +
    299 				template.HTMLEscapeString(path.Dir(strings.TrimSuffix(urlPath, "/"))) +
    300 				`">../</a>` + "\n",
    301 		)
    302 	} else {
    303 		b.WriteString(
    304 			`<a href="/">../</a>` + "\n",
    305 		)
    306 	}
    307 
    308 	nameCol := 50
    309 	for _, ei := range list {
    310 		escapedName := template.HTMLEscapeString(ei.name)
    311 		dateStr := ei.mod.Local().Format("02-Jan-2006 15:04")
    312 		var sizeStr string
    313 		if ei.isDir {
    314 			sizeStr = "-"
    315 		} else {
    316 			sizeStr = fmt.Sprintf("%d", ei.size)
    317 		}
    318 		spaces := 1
    319 		if len(escapedName) < nameCol {
    320 			spaces = nameCol - len(escapedName)
    321 		}
    322 		line := `<a href="` + template.HTMLEscapeString(ei.href) + `">` + escapedName + `</a>` + strings.Repeat(" ", spaces) + dateStr + strings.Repeat(" ", 19-len(sizeStr)) + sizeStr + "\n"
    323 		b.WriteString(line)
    324 	}
    325 	b.WriteString("</pre><hr></body>\n</html>")
    326 	return b.String(), nil
    327 }
    328 
    329 /* generates the data for the not found page. Checks for user-defined source in this order
    330  * 404.lua => 404.md => 404.html => default.
    331  */
    332 func generateNotFoundData(cfg *config.AppConfig) []byte {
    333 	notFoundData := []byte(`
    334 <html>
    335 <head><title>404 Not Found</title></head>
    336 <body>
    337 <center><h1>404 Not Found</h1></center>
    338 <hr><center>fes</center>
    339 </body>
    340 </html>
    341 `)
    342 	if _, err := os.Stat(filepath.Join("www", "404.lua")); err == nil {
    343 		if nf, err := renderRoute("www/404.lua", cfg, reqData{}); err == nil {
    344 			notFoundData = nf
    345 		}
    346 	} else if _, err := os.Stat("www/404.md"); err == nil {
    347 		if buf, err := os.ReadFile("www/404.html"); err == nil {
    348 			notFoundData = []byte(markdownToHTML(string(buf)))
    349 		}
    350 	} else if _, err := os.Stat("www/404.html"); err == nil {
    351 		if buf, err := os.ReadFile("www/404.html"); err == nil {
    352 			notFoundData = buf
    353 		}
    354 	}
    355 	return notFoundData
    356 }
    357 
    358 /* helper to load all special directories */
    359 func loadDirs() map[string]string {
    360 	routes := make(map[string]string)
    361 
    362 	if entries, err := os.ReadDir("www"); err == nil {
    363 		if err := handleDir(entries, "www", routes, "", false); err != nil {
    364 			ui.Warning("failed to handle www directory", err)
    365 		}
    366 	}
    367 
    368 	if entries, err := os.ReadDir("static"); err == nil {
    369 		if err := handleDir(entries, "static", routes, "/static", true); err != nil {
    370 			ui.Warning("failed to handle static directory", err)
    371 		}
    372 	}
    373 
    374 	if entries, err := os.ReadDir("archive"); err == nil {
    375 		if err := handleDir(entries, "archive", routes, "/archive", true); err != nil {
    376 			ui.Warning("failed to handle archive directory", err)
    377 		}
    378 	}
    379 
    380 	return routes
    381 }
    382 
    383 /* helper to parse the Fes.toml and generate config */
    384 func parseConfig() config.AppConfig {
    385 	defaultCfg := config.AppConfig{}
    386 	defaultCfg.App.Authors = []string{"unknown"}
    387 	defaultCfg.App.Name = "unknown"
    388 	defaultCfg.App.Version = "unknown"
    389 
    390 	tomlDocument, err := os.ReadFile("Fes.toml")
    391 	if err != nil {
    392 		if errors.Is(err, os.ErrNotExist) {
    393 			ui.WARN("no config file found, using the default config. In order to specify a config file write to Fes.toml")
    394 			return defaultCfg
    395 		} else {
    396 			ui.Error("failed to read Fes.toml", err)
    397 			os.Exit(1)
    398 		}
    399 	}
    400 	docStr := fixMalformedToml(string(tomlDocument))
    401 	var cfg config.AppConfig
    402 	if err := toml.Unmarshal([]byte(docStr), &cfg); err != nil {
    403 		ui.Warning("failed to parse Fes.toml", err)
    404 		cfg = defaultCfg
    405 	}
    406 	return cfg
    407 }
    408 
    409 /* helper to read the archive files */
    410 func readArchive(w http.ResponseWriter, route string) error {
    411 	fsPath := "." + route
    412 	if info, err := os.Stat(fsPath); err == nil && info.IsDir() {
    413 		if page, err := generateArchiveIndex(fsPath, route); err == nil {
    414 			w.Write([]byte(page))
    415 			return nil
    416 		} else {
    417 			return err
    418 		}
    419 	}
    420 	return nil
    421 }
    422 
    423 /* start the Fes server */
    424 func Start(dir string) error {
    425 	if err := os.Chdir(dir); err != nil {
    426 		return ui.Error(fmt.Sprintf("failed to change directory to %s", dir), err)
    427 	}
    428 
    429 	ui.Log("Running root=%s, port=%d.", filepath.Clean(dir), *config.Port)
    430 
    431 	cfg := parseConfig()
    432 	notFoundData := generateNotFoundData(&cfg)
    433 	routes := loadDirs()
    434 
    435 	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
    436 		route, ok := routes[r.URL.Path]
    437 
    438 		var err error = nil
    439 
    440 		/* defer won't update paramaters unless we do this. */
    441 		defer func() {
    442 			ui.Path(route, err)
    443 		}()
    444 
    445 		if !ok {
    446 			err = config.ErrRouteMiss
    447 			route = r.URL.Path
    448 
    449 			if strings.HasPrefix(route, "/archive") {
    450 				err = readArchive(w, route)
    451 			} else {
    452 				w.WriteHeader(http.StatusNotFound)
    453 				w.Write([]byte(notFoundData))
    454 			}
    455 			return
    456 		}
    457 
    458 		params := make(map[string]string)
    459 		for k, v := range r.URL.Query() {
    460 			if len(v) > 0 {
    461 				params[k] = v[0]
    462 			}
    463 		}
    464 
    465 		var data []byte
    466 		if strings.HasSuffix(route, ".lua") {
    467 			data, err = renderRoute(route, &cfg, reqData{path: r.URL.Path, params: params})
    468 		} else if strings.HasSuffix(route, ".md") {
    469 			data, err = os.ReadFile(route)
    470 			data = []byte(markdownToHTML(string(data)))
    471 			data = []byte("<style>body {max-width: 80ch;}</style>\n" + string(data))
    472 		} else {
    473 			data, err = os.ReadFile(route)
    474 		}
    475 
    476 		if err != nil {
    477 			http.Error(w, fmt.Sprintf("Error loading page: %v", err), http.StatusInternalServerError)
    478 		}
    479 
    480 		w.Write(data)
    481 	})
    482 	ui.Log("Server initialized")
    483 
    484 	ui.Log("Ready to accept connections tcp")
    485 	return http.ListenAndServe(fmt.Sprintf("0.0.0.0:%d", *config.Port), nil)
    486 }