tweak file manager UI and route, add file-move feature, add EurekaAdapter formatting help, and update README

This commit is contained in:
Iris Lightshard 2022-06-15 01:33:56 -06:00
parent 119d66cd27
commit 87333f9d87
Signed by: Iris Lightshard
GPG key ID: 3B7FBC22144E6398
17 changed files with 361 additions and 67 deletions

View file

@ -13,8 +13,11 @@ Clone this repository and run `go build` to build `nirvash`. Just running `./nir
```
adapter=eureka // one of the supported adapters, currently just eureka
root=/path/to/ssg/root // path to where your SSG content root is
assetRoot=/path/to/asset/root // path to the Nirvash static assets (eg static/ directory in this repo)
assetRoot=/path/to/asset/root // path to the parent folder containing Nirvash static/ assets and templates/ directory (eg base directory of this repo)
staticRoot=/path/to/static/root // path to static file storage on your webserver
staticShowHTML=false // true or false, show HTML files in the file manager interface
staticShowHidden=false // true or false, show hidden files in the file manager interface
staticMaxUploadMB=25 // integer, maximum size in MB of files uploaded in the file manager interface
plugins=none // list of plugins to use, currently none are implemented
```
@ -30,4 +33,31 @@ User management is done from the command line as well:
Running `nirvash` without any arguments starts the webserver on port 8080.
MORE TO COME
Initially the user will be presented with the login screen; upon successful login, the application presents the navbar with these options:
- `Pages`: the default page, shows a list of existing pages - clicking one enables editing that page; a button is also presented for adding a new page. Each `Adapter` will provide different formatting help and can allow editable slugs/URLs or not (eg, the `EurekaAdapter` builds slugs/URLs directly from the page title).
- `Files`: provides an interface for managing statically hosted files. Files and directories can be added, moved, and deleted.
- `Build`: a simple form to build the site - build options configurable by `Adapter` are present under an accordion.
- `Configuration`: interface to the configuration for the `Adapter`. Each `Adapter` provides its own configuration interface with associated data types (currently supported: `int`, `float`, `string`, and `multilinestring`)
- `Logout`: logs the user out and returns to the login screen
## adapter interface
`nirvash` is extensible by `Adapter`s that can interact with almost any static site generator under the hood.
The `Adapter` interface and associated data types can be found in the [adapter.go](https://nilfm.cc/git/nirvash/tree/archetype/adapter.go) file, but the basic interface looks like this:
- `Init(cfg *Config)`: set any initial settings that need to be handled - typically import SSG data root from the `nirvash` config file and read the SSG's own config file
- `Name() string`: the name of the adapter, used for the `nirvash.conf` config file
- `EditableSlugs() bool`: whether slugs can be edited independently or are calculated based on, eg, page titles
- `BuildOptions() []string`: a list of names of the build options to present on the `/build` page
- `GetConfig() map[ConfigOption]string`: retrieves the config to present on the `/config` page
- `SetConfig(map[ConfigOption]string) error`: takes the config from the `/config` page and applies it, typically by writing it to a file
- `ListPages() map[string]string`: list the pages in the site; keys are the slugs, values are the titles
- `GetPage(slug string) Page`: given a slug, return the page data
- `FormatPage(string) string`: given the raw page data as input by the user, return HTML suitable for preview (currently unimplemented and unused)
- `FormattingHelp() string`: return a string to be inserted into a `pre` tag on the `/fmt-help` page
- `CreatePage(slug, title, content string) error`: given all the proper arguments, create a new page in the backing store (eg filesystem, db)
- `SavePage(oldSlug, newSlug, title, content string) error`: given all the proper arguments, save a page to the backing store (eg filesystem, db)
- `DeletePage(slug string) error`: given a slug, delete the corresponding page source and possibly its generated HTML, depending on the `Adapter`
- `Build(buildOptions map[string][]string) BuildStatus`: takes a map of build option names to their values and builds the site, returning a `BuildStatus` object containing the success or failure as a boolean and the detailed status (eg, the console ouptut of the build process)

View file

@ -29,8 +29,8 @@ type Adapter interface {
GetConfig() map[ConfigOption]string
SetConfig(map[ConfigOption]string) error
ListPages() map[string]string
GetPage(string) Page
FormatPage(string) string
GetPage(slug string) Page
FormatPage(raw string) string
FormattingHelp() string
CreatePage(slug, title, content string) error
SavePage(oldSlug, newSlug, title, content string) error

View file

@ -5,17 +5,19 @@ import (
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
)
type Config struct {
Adapter Adapter // adapter for this instance
Root string // root of the site data
StaticRoot string // root of static files for StaticFileManager
StaticShowHidden bool // whether to show hidden files in the StaticFileManager
StaticShowHtml bool // whether to show html files in the StaticFileManager
AssetRoot string // root of Nirvash dist files (CSS, images)
Plugins map[string]interface{}
Adapter Adapter // adapter for this instance
Root string // root of the site data
StaticRoot string // root of static files for StaticFileManager
StaticShowHidden bool // whether to show hidden files in the FileManager
StaticShowHTML bool // whether to show html files in the FileManager
StaticMaxUploadMB int64 // max size in MB of files uploaded via FileManager
AssetRoot string // root of Nirvash dist files (CSS, images)
Plugins map[string]interface{}
}
func GetConfigLocation() string {
@ -63,6 +65,7 @@ func (self *Config) SetAdapter(adapter string) {
}
func (self *Config) IsNull() bool {
// zero-values for StaticShowHTML, StaticShowHidden, and StaticMaxUploadMB are valid
return self.Adapter == nil || len(self.Root) == 0 || len(self.StaticRoot) == 0 || len(self.AssetRoot) == 0
}
@ -88,11 +91,22 @@ func (self *Config) RunWizard() {
self.Root = inputBuf
inputBuf = ""
fmt.Printf("static file root? ")
ensureNonEmptyOption(&inputBuf)
self.StaticRoot = inputBuf
inputBuf = ""
fmt.Printf("show HTML files in file manager? ")
self.StaticShowHTML = ensureBooleanOption(&inputBuf)
inputBuf = ""
fmt.Printf("show hidden files in file manager? ")
self.StaticShowHidden = ensureBooleanOption(&inputBuf)
inputBuf = ""
fmt.Printf("max upload size (MB)? ")
self.StaticMaxUploadMB = ensureNumberOption(&inputBuf)
inputBuf = ""
fmt.Printf("nirvash asset root? ")
ensureNonEmptyOption(&inputBuf)
@ -113,6 +127,31 @@ func ensureNonEmptyOption(buffer *string) {
if len(strings.TrimSpace(*buffer)) != 0 {
break
}
fmt.Println("Please enter a nonempty value")
}
}
func ensureBooleanOption(buffer *string) bool {
for {
fmt.Scanln(buffer)
trimmedBuf := strings.TrimSpace(*buffer)
v, err := strconv.ParseBool(trimmedBuf)
if err == nil {
return v
}
fmt.Println("Please enter a true or false value")
}
}
func ensureNumberOption(buffer *string) int64 {
for {
fmt.Scanln(buffer)
trimmedBuf := strings.TrimSpace(*buffer)
v, err := strconv.ParseInt(trimmedBuf, 10, 64)
if err == nil && v > 0 {
return v
}
fmt.Println("Please enter a positive integer")
}
}
@ -126,6 +165,9 @@ func writeConfig(cfg *Config, configFile string) error {
f.WriteString("root=" + cfg.Root + "\n")
f.WriteString("staticRoot=" + cfg.StaticRoot + "\n")
f.WriteString("staticShowHTML=" + strconv.FormatBool(cfg.StaticShowHTML) + "\n")
f.WriteString("staticShowHidden=" + strconv.FormatBool(cfg.StaticShowHidden) + "\n")
f.WriteString("staticMaxUploadMB=" + strconv.FormatInt(cfg.StaticMaxUploadMB, 10) + "\n")
f.WriteString("assetRoot=" + cfg.AssetRoot + "\n")
f.WriteString("adapter=" + cfg.Adapter.Name() + "\n")
f.WriteString("plugins=\n")
@ -159,6 +201,12 @@ func parseConfig(configFile string) *Config {
cfg.Root = v
case "staticRoot":
cfg.StaticRoot = v
case "staticShowHTML":
cfg.StaticShowHTML, _ = strconv.ParseBool(v)
case "staticShowHidden":
cfg.StaticShowHidden, _ = strconv.ParseBool(v)
case "staticMaxUploadMB":
cfg.StaticMaxUploadMB, _ = strconv.ParseInt(v, 10, 64)
case "assetRoot":
cfg.AssetRoot = v
case "plugins":

View file

@ -112,7 +112,70 @@ func (self *EurekaAdapter) FormatPage(raw string) string {
func (self *EurekaAdapter) FormattingHelp() string {
// TODO: show Eureka formatting guide
return "help!"
return `// shorthand for linking other pages
{page name}
// shorthand for page transclusion
{/page name}
// shorthand for arbitary link
{*destination url|text}
// shorthand for an image you can click to see the full sized version
{:anchor-id|image url|alt text}
// shorthand for an image with arbitrary link destination
{?anchor-id|destination url|image url|alt text}
// shorthand for an audio player
{_/path/to/media}
// shorthand for paragraphs, can embed other markup inside it
{&paragraph text {with a link} {@and some bold text}}
// shorthand for ordered lists, can embed other markup inside it
{#
{-item one}
{-item two}
{-item three}
}
// shorthand for unordered lists, can embed other markup inside it
{,
{-item one}
{-item two}
{-item three}
}
// shorthand for bold
{@bold text}
// shorthand for italic
{~italic text}
// shorthand for code
{` + "`" + `short code}
// shorthand for pre
{$longer code}
// shorthand for quote
{'short quote}
// shorthand for blockquote
{>longer quote}
// shorthand for strikethrough
{\crossed-out text}
// shorthand for level 3 heading
{!heading text}
// shorthand for level 4 heading
{.heading text}
// shorthand for publish date (renders as <time class='publish-date'>)
{+2022-02-22}`
}
func (self *EurekaAdapter) CreatePage(slug, title, content string) error {

View file

@ -10,9 +10,10 @@ import (
)
type SimpleFileManager struct {
Root string
ShowHtml bool
ShowHidden bool
Root string
ShowHTML bool
ShowHidden bool
maxUploadMB int64
}
type FileData struct {
@ -37,13 +38,15 @@ type FileManager interface {
AddFile(path string, req *http.Request) error
MkDir(path, newDir string) error
Remove(path string) error
// Rename(old, new string) error
Rename(oldFullPath, newPath, newName string) error
MaxUploadMB() int64
}
func (self *SimpleFileManager) Init(cfg *Config) error {
self.Root = filepath.Clean(cfg.StaticRoot)
self.ShowHtml = cfg.StaticShowHtml
self.ShowHTML = cfg.StaticShowHTML
self.ShowHidden = cfg.StaticShowHidden
self.maxUploadMB = cfg.StaticMaxUploadMB
return nil
}
@ -77,7 +80,10 @@ func (self *SimpleFileManager) ListSubTree(root string) FileListing {
list.Up = "/"
}
if len(levels) >= 2 {
list.Up = "/" + strings.Join(levels[:len(levels)-1], "/")
list.Up = strings.Join(levels[:len(levels)-1], "/")
if !strings.HasPrefix(list.Up, "/") {
list.Up = "/" + list.Up
}
}
for _, file := range files {
@ -87,7 +93,7 @@ func (self *SimpleFileManager) ListSubTree(root string) FileListing {
if file.IsDir() {
list.SubDirs = append(list.SubDirs, file.Name())
} else {
if !self.ShowHtml && strings.HasSuffix(file.Name(), ".html") {
if !self.ShowHTML && strings.HasSuffix(file.Name(), ".html") {
continue
}
list.Files = append(list.Files, file.Name())
@ -147,7 +153,7 @@ func (self *SimpleFileManager) AddFile(path string, req *http.Request) error {
return errors.New("You cannot escape!")
}
req.ParseMultipartForm(250 << 20)
req.ParseMultipartForm(self.maxUploadMB << 20)
file, header, err := req.FormFile("file")
if err != nil {
return err
@ -192,3 +198,28 @@ func (self *SimpleFileManager) MkDir(path, newDir string) error {
return os.Mkdir(newDirPath, 0755)
}
func (self *SimpleFileManager) Rename(oldFullPath, newPath, newName string) error {
fullPath := filepath.Join(self.Root, oldFullPath)
_, err := os.Stat(fullPath)
if err != nil {
return err
}
newParent := filepath.Join(self.Root, newPath)
_, err = os.Stat(newParent)
if err != nil {
return err
}
if newName == "" {
_, oldName := filepath.Split(oldFullPath)
newName = oldName
}
return os.Rename(fullPath, filepath.Join(newParent, newName))
}
func (self *SimpleFileManager) MaxUploadMB() int64 {
return self.maxUploadMB
}

View file

@ -75,21 +75,9 @@ func FormMapToAdapterConfig(next http.Handler, adapter core.Adapter) http.Handle
return http.HandlerFunc(handlerFunc)
}
func WithFileData(next http.Handler, fileManager core.FileManager) http.Handler {
func PrepareForUpload(next http.Handler, fileManager core.FileManager) http.Handler {
handlerFunc := func(w http.ResponseWriter, req *http.Request) {
ctx := req.Context()
fileSlug := ctx.Value("params").(map[string]string)["Slug"]
fileData := fileManager.GetFileData(fileSlug)
*req = *req.WithContext(context.WithValue(req.Context(), "file-data", fileData))
next.ServeHTTP(w, req)
}
return http.HandlerFunc(handlerFunc)
}
func PrepareForUpload(next http.Handler) http.Handler {
handlerFunc := func(w http.ResponseWriter, req *http.Request) {
req.ParseMultipartForm(250 << 20)
req.ParseMultipartForm(fileManager.MaxUploadMB() << 20)
next.ServeHTTP(w, req)
}

View file

@ -210,13 +210,13 @@ func main() {
"/"))
rtr.Get(
`/static-mgr`,
`/file-mgr`,
http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
http.Redirect(w, req, "/static-mgr/", http.StatusSeeOther)
http.Redirect(w, req, "/file-mgr/", http.StatusSeeOther)
}))
rtr.Get(
`/static-mgr/(?P<Slug>.*)`,
`/file-mgr/(?P<Slug>.*)`,
Fortify(
Protected(
WithFileManager(
@ -234,17 +234,45 @@ func main() {
Fortify(
Protected(
WithFileManager(
WithFileData(
renderer.Template(
pathConcat(templateRoot, "file_actions.html"),
pathConcat(templateRoot, "header.html"),
pathConcat(templateRoot, "footer.html")),
fileManager),
renderer.Template(
pathConcat(templateRoot, "file_actions.html"),
pathConcat(templateRoot, "header.html"),
pathConcat(templateRoot, "footer.html")),
fileManager),
http.MethodGet,
udb,
"/login")))
rtr.Get(
`/file-move/(?P<Slug>.*)`,
Fortify(
Protected(
WithFileManager(
renderer.Template(
pathConcat(templateRoot, "file_move.html"),
pathConcat(templateRoot, "header.html"),
pathConcat(templateRoot, "footer.html")),
fileManager),
http.MethodGet,
udb,
"/login")))
rtr.Post(
`/file-move-process/(?P<Slug>.*)`,
Defend(
Protected(
WithFileManager(
renderer.Template(
pathConcat(templateRoot, "file_move_process.html"),
pathConcat(templateRoot, "header.html"),
pathConcat(templateRoot, "footer.html")),
fileManager),
http.MethodGet,
udb,
"/login"),
udb,
"/"))
rtr.Post(
`/file-delete/(?P<Slug>.*)`,
Defend(
@ -290,7 +318,8 @@ func main() {
udb,
"/login"),
udb,
"/")))
"/"),
fileManager))
rtr.Get(
`/mkdir/(?P<Slug>.*)`,
@ -321,12 +350,18 @@ func main() {
"/login"),
udb,
"/"))
// TODO:
// add directory POST performs the action of creating directory
// move-choose POST uses a form to navigate through the file tree
// - to use links to navigate, destination location uses the slug,
// - and file to move uses the POST parameters
// move-do POST moves the file when finalized in move-choose
rtr.Get(
`/fmt-help`,
Protected(
WithAdapter(
renderer.Template(
pathConcat(templateRoot, "format_help.html"),
pathConcat(templateRoot, "header.html"),
pathConcat(templateRoot, "footer.html")),
cfg.Adapter),
http.MethodGet,
udb,
"/login"))
http.ListenAndServe(":8080", rtr)
}

View file

@ -149,7 +149,7 @@ a:hover {
overflow-y: visible;
}
a.new-page-button {
.new-page-button {
position: relative;
top: 1em;
text-decoration: none;
@ -164,7 +164,7 @@ a.new-page-button {
margin-bottom: 0.2em;
}
a.new-page-button:hover {
.new-page-button:hover {
background: lightgray;
color: black;
}
@ -196,13 +196,13 @@ span.adapter-error {
border-bottom: 2px solid crimson;
}
form.editor label, form.build label, .danger-zone label, form.configurator label, form.file-move label, .mkdir label {
form.editor label, form.build label, .danger-zone label, form.configurator label, form.file-move label, .mkdir label, .page-list label {
font-size: 80%;
color: lightgray;
text-transform: uppercase;
}
form.editor input, form.build input, form.editor textarea, form.configurator input, form.configurator textarea, .danger-zone input[type="submit"], .file-move input[type="submit"], .uploader label, .uploader input[type="submit"], .mkdir input {
form.editor input, form.build input, form.editor textarea, form.configurator input, form.configurator textarea, .danger-zone input[type="submit"], .file-move input[type="submit"], .uploader label, .uploader input[type="submit"], .mkdir input, .page-list input[type="text"] {
display: block;
margin: 0;
margin-top: 0.2em;
@ -247,7 +247,7 @@ form input:focus, form textarea:focus {
}
form.editor, .wide {
max-width: 80em;
max-width: 80em !important;
}
form.editor textarea {
@ -294,10 +294,18 @@ form.editor input[type="submit"]:hover,form.build input[type="submit"]:hover, .d
z-index: 1;
}
.page-list ul li a {
.page-list ul li {
line-height: 2em;
}
.page-list ul li span.file-nolink {
color: #7f7f7f;
}
.left-pad-uplink {
padding-left: 20px;
}
form input[hidden] {
display: none;
}
@ -318,10 +326,15 @@ form input[readonly] {
.file-actions-icon {
display: inline-block;
opacity: 0;
max-height: 16px;
width: 16px;
}
.file-list li:hover > .file-actions-icon, .file-list li:focus-within > .file-actions-icon {
opacity: 1;
}
input[type="file"] {
opacity: 0;
position: absolute;
@ -331,7 +344,6 @@ input[type="file"] {
width: 110px;
}
input[type="file"]:not(:valid) + .file-feedback::after {
content: "No file selected";
height: 1em;

View file

@ -29,6 +29,7 @@
<span class="edited-time">last edited {{($page).Edited.Format "2006-01-02 15:04"}}</span><br/>
<label for="content">Content</label><br/>
<textarea class="content-input" id="content" name="content" required>{{($page).Content}}</textarea><br/>
<a target="_blank" class="fmt-help" href="/fmt-help">Formatting help</a>
<input type="submit" value="Save"/>
</form>

View file

@ -19,6 +19,7 @@
{{ end }}
<label for="content">Content</label><br/>
<textarea class="content-input" id="content" name="content" required></textarea><br/>
<a target="_blank" class="fmt-help" href="/fmt-help">Formatting help</a>
<input type="submit" value="Save"/>
</form>

View file

@ -1,5 +1,5 @@
{{ $slug := ((.Context).Value "params").Slug }}
{{ $file := (.Context).Value "file-data" }}
{{ $file := ((.Context).Value "file-manager").GetFileData $slug }}
{{ $csrfToken := (.Context).Value "csrfToken" }}
{{ template "header" . }}
@ -17,9 +17,8 @@
{{end}}
<div class="action-panel">
<form class="file-move" method="POST" action="/move-select/{{($file).Path}}">
<form class="file-move" method="GET" action="/file-move/{{($file).Path}}">
<span>/{{($file).Path}}</span>
<input hidden name="csrfToken" value="{{$csrfToken}}"/>
<input type="submit" value="Move/Rename"/>
</form>
<details class="danger-zone"><summary>Danger Zone</summary>

View file

@ -19,12 +19,12 @@
<div class="page-list">
<ul class="file-list">
{{ if ($fileList).Up }}
<li><a href="/static-mgr{{$fileList.Up}}">..</a></li>
<li><a class="left-pad-uplink" href="/file-mgr{{$fileList.Up}}">..</a></li>
{{ end }}
{{ range $dir := ($fileList).SubDirs }}
<li>
<a class="file-actions-icon" href="/file-actions{{($fileList).Root}}{{$dir}}"><img src="/static/actions.png" width="16px" height="16px" alt="actions"/></a>
<a href="/static-mgr{{($fileList).Root}}{{$dir}}">{{$dir}}/</a>
<a href="/file-mgr{{($fileList).Root}}{{$dir}}">{{$dir}}/</a>
</li>
{{ end }}
{{ range $file := ($fileList).Files }}

55
templates/file_move.html Normal file
View file

@ -0,0 +1,55 @@
{{ $slug := ((.Context).Value "params").Slug }}
{{ $dest := .FormValue "dest" }}
{{ $fileList := ((.Context).Value "file-manager").ListSubTree $dest }}
{{ $fileData := ((.Context).Value "file-manager").GetFileData $slug }}
{{ $csrfToken := (.Context).Value "csrfToken" }}
{{ template "header" .}}
{{ if ($fileList).Error}}
<h2>File Listing Error</h2>
<span class="adapter-error">{{($fileList).Error}}</span>
{{ else if ($fileData).Error }}
<h2>File Listing Error</h2>
<span class="adapter-error">{{($fileData).Error}}</span>
{{ else }}
<h2>Moving {{($fileData).Name}}: {{($fileList).Root}}</h2>
<form class="move-rename-file" method="POST" action="/file-move-process/{{($fileData).Path}}">
<input hidden type="text" name="csrfToken" value="{{$csrfToken}}"/>
<input hidden type="text" name="dest" value="{{($fileList).Root}}"/>
<div class="new-page-button-wrapper">
<input type="submit" class="new-page-button" value="Move here"/>
</div>
<div class="page-list">
<label>New file name
<input type="text" name="filename" value="{{($fileData).Name}}"/>
</label>
<ul class="file-list">
{{ if ($fileList).Up }}
<li><a href="/file-move/{{($fileData).Path}}?dest={{($fileList).Up}}">..</a></li>
{{ end }}
{{ range $dir := ($fileList).SubDirs }}
<li>
<a href="/file-move/{{($fileData).Path}}?dest={{($fileList).Root}}{{$dir}}">{{$dir}}/</a>
</li>
{{ end }}
{{ range $file := ($fileList).Files }}
<li>
<span class="file-nolink">{{$file}}</span>
</li>
{{ end }}
</ul>
</div>
</form>
{{ end }}
{{ template "footer" .}}

View file

@ -0,0 +1,22 @@
{{ $slug := ((.Context).Value "params").Slug }}
{{ $dest := .FormValue "dest" }}
{{ $name := .FormValue "filename" }}
{{ $moveError := ((.Context).Value "file-manager").Rename $slug $dest $name }}
{{ template "header" . }}
{{ if $moveError }}
<h2>File Move/Rename Error</h2>
<span class="adapter-error">{{$moveError}}</span>
{{ else }}
<h2>File Move/Rename Success</h2>
<span class="adapter-success">File moved from {{$slug}} to {{$dest}}{{$name}} </span>
{{ end }}
{{ template "footer.html" . }}

View file

@ -1,13 +1,13 @@
{{ $slug := ((.Context).Value "params").Slug }}
{{ $csrfToken := (.Context).Value "csrfToken" }}
{{ $fileListing := ((.Context).Value "file-manager").ListSubtree $slug }}
{{ $fileData := ((.Context).Value "file-manager").GetFileData $slug }}
{{ template "header" . }}
{{ if ($fileListing).Error }}
{{ if ($fileData).Error }}
<h2>Error</h2>
<span class="adapter-error">{{($fileListing).Error}}</span>
<span class="adapter-error">{{($fileData).Error}}</span>
{{ else }}

View file

@ -0,0 +1,9 @@
{{ $fmtHelp := ((.Context).Value "adapter").FormattingHelp }}
{{ template "header" . }}
<h2>Formatting Help</h2>
<span class="adapter-success wide"><pre>{{ $fmtHelp }}</pre></span>
{{ template "footer" . }}

View file

@ -13,7 +13,7 @@
<nav>
<ul>
<li><a href="/">Pages</a></li>
<li><a href="/static-mgr/">Static Files</a></li>
<li><a href="/file-mgr">Files</a></li>
<li><a href="/build">Build</a></li>
<li><a href="/config">Configuration</a></li>
<li><a href="/logout">Logout</a></li>