fix style, implement config load/save
PGP Signature
-----BEGIN PGP SIGNATURE----- iQIzBAABCAAdFiEEkFh6dA+k/6CXFXU4O3+8IhROY5gFAmKgN1YACgkQO3+8IhRO Y5is3g/8DzonEPRBe9V6zC2bKcKp0chFGZ/cyCBIhErY1cNm6lSnJOdflr+O4f40 SdzZae3mNxGLvkp99O2hpb9fXFwTw1lX3a+DrYgLZ4nS9yJz/6VxMDD3Fl2znHx0 LeWg2nylU622OPOF9WaoBy+rRA7RPlcCdR8uhIVT1afsRk3+UgW2nyN0toV5Taxw Xc8dYYFoqC5pjNne57q+efGMFy+1BewCARtanEF9NgsA26IWClCTmMhXGh7S1H1I 3UedOfXsiMzaBIVZHk9OWqQ37A/d9RQyt2Lcbo9Qls/aYkWhSxIikx9BgYfgLXIY zPKhssAilmNqnS2obk4nztFTms6+cVgW1+a32SCp+LAVh8cAiSgMSnkJXZgtZdHJ gxnYrHd3SAun/CJ46RAv6u8FDK6dTN+T2Ubd6jzzNfQ9mBLyOdEczEm+ZInxv2Hs MDzapdAs4o5mR5yyPcK8sNTv4RpOVWMQRclSJRpJxFpdyQBDNMGtk92MOU+RDsyg 7W1kMIcodLDCyL9HZJJ8SDudW+BFFeLqqbnc2OELdKxBAd5Rd42yfBZbU3YNi/A/ TQntBZWTNjd+YFF2dS0K9LulEsi9jHBQTCmlTif3L2yEtHgHWOiCqqH23GCzxfBX k6ZeHwvxyw5ftTQFc1GOGPRXZ4K/lr1qzi/CQ+vxKfy6KP6kI1Q= =5pI+ -----END PGP SIGNATURE-----
@@ -5,13 +5,18 @@ Success bool
Message string } +type ConfigOption struct { + Name string + Type string +} + type Adapter interface { Init(cfg *Config) Name() string EditableSlugs() bool BuildOptions() []string - GetConfig(key string) (interface{}, error) - SetConfig(key string, value interface{}) error + GetConfig() map[ConfigOption]string + SetConfig(map[ConfigOption]string) error ListPages() map[string]string GetPage(string) (Page, error) FormatPage(string) string
@@ -6,12 +6,13 @@ "io/ioutil"
"os" "os/exec" "path/filepath" + "strconv" "strings" ) type EurekaAdapter struct { Root string - Config map[string]interface{} + Config map[ConfigOption]string } func (self *EurekaAdapter) Init(cfg *Config) {@@ -23,8 +24,12 @@ panic("SSG content root is not a directory!")
} self.Root = cfg.Root - + self.Config = make(map[ConfigOption]string) // TODO: read config.h and build self.Config + err = self.readCfg() + if err != nil { + panic("config.h is malformed!") + } } func (self *EurekaAdapter) Name() string {@@ -39,12 +44,13 @@ func (self *EurekaAdapter) BuildOptions() []string {
return []string{"twtxt"} } -func (self *EurekaAdapter) GetConfig(key string) (interface{}, error) { - return nil, nil +func (self *EurekaAdapter) GetConfig() map[ConfigOption]string { + return self.Config } -func (self *EurekaAdapter) SetConfig(key string, value interface{}) error { - return nil +func (self *EurekaAdapter) SetConfig(cfg map[ConfigOption]string) error { + self.Config = cfg + return self.writeCfg() } func (self *EurekaAdapter) ListPages() map[string]string {@@ -85,7 +91,6 @@ content := string(f[:])
return Page{ Title: title, - Slug: filename, Content: content, Edited: fileInfo.ModTime(), }, nil@@ -129,7 +134,15 @@ }
defer f.Close() if oldSlug != newSlug { - // TODO: delete old html as well + siteRoot := self.Config[ConfigOption{ + Name: "SITEROOT", + Type: "string", + }] + htmlFile := filepath.Join(self.Root, siteRoot, oldSlug+"l") + _, err := os.Stat(htmlFile) + if !os.IsNotExist(err) { + os.Remove(htmlFile) + } os.Remove(filepath.Join(self.Root, "inc", oldSlug)) }@@ -138,7 +151,15 @@ return nil
} func (self *EurekaAdapter) DeletePage(slug string) error { - // TODO: delete old html as well + siteRoot := self.Config[ConfigOption{ + Name: "SITEROOT", + Type: "string", + }] + htmlFile := filepath.Join(self.Root, siteRoot, slug+"l") + _, err := os.Stat(htmlFile) + if !os.IsNotExist(err) { + os.Remove(htmlFile) + } return os.Remove(filepath.Join(self.Root, "inc", slug)) }@@ -147,7 +168,7 @@
twtxt := buildOptions["twtxt"][0] cmdArgs := "" if twtxt != "" { - cmdArgs += " -t " + twtxt + cmdArgs += "-t " + twtxt } cmd := exec.Command("./build.sh", cmdArgs)@@ -159,3 +180,111 @@ Success: err == nil,
Message: string(out), } } + +func (self *EurekaAdapter) readCfg() error { + configPath := filepath.Join(self.Root, "config.h") + _, err := os.Stat(filepath.Join(self.Root, "config.h")) + if os.IsNotExist(err) { + configPath = filepath.Join(self.Root, "config.def.h") + } + f, err := os.ReadFile(configPath) + + if err != nil { + return err + } + + fileData := string(f[:]) + + macros := strings.Split(fileData, "#define ")[1:] + for _, macro := range macros { + tokens := strings.Split(strings.TrimSpace(macro), " ") + k := tokens[0] + v := strings.TrimSpace(strings.Join(tokens[1:], " ")) + + if strings.Contains(v, "\"") { + if strings.Contains(v, "\\\r\n") || strings.Contains(v, "\\\n") { + // process multiline string + lines := strings.Split(v, "\n") + cleanedString := "" + for _, l := range lines { + l = strings.TrimSuffix(l, "\r") + l = strings.TrimSuffix(l, "\\") + l = strings.TrimSpace(l) + l = strings.TrimPrefix(l, "\"") + l = strings.TrimSuffix(l, "\"") + l = strings.ReplaceAll(l, "\\\"", "\"") + l = strings.ReplaceAll(l, "\\n", "\n") + cleanedString += l + } + self.Config[ConfigOption{ + Name: k, + Type: "multilinestring", + }] = cleanedString + } else { + cleanedString := strings.TrimSuffix(strings.TrimPrefix(v, "\""), "\"") + cleanedString = strings.ReplaceAll(cleanedString, "\\n", "\n") + cleanedString = strings.ReplaceAll(cleanedString, "\r", "") + cleanedString = strings.ReplaceAll(cleanedString, "\\\"", "\"") + self.Config[ConfigOption{ + Name: k, + Type: "string", + }] = cleanedString + } + } else if strings.Contains(v, ".") { + _, err := strconv.ParseFloat(v, 64) + if err != nil { + return err + } + self.Config[ConfigOption{ + Name: k, + Type: "float", + }] = v + } else { + _, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return err + } + self.Config[ConfigOption{ + Name: k, + Type: "int", + }] = v + } + } + return nil +} + +func (self *EurekaAdapter) writeCfg() error { + f, err := os.Create(filepath.Join(self.Root, "config.h")) + if err != nil { + return err + } + + defer f.Close() + + for k, v := range self.Config { + switch k.Type { + case "int": + _, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return err + } + f.WriteString("#define " + k.Name + " " + v + "\n") + case "float": + _, err := strconv.ParseFloat(v, 64) + if err != nil { + return err + } + f.WriteString("#define " + k.Name + " " + v + "\n") + case "string": + fallthrough + case "multilinestring": + v = strings.ReplaceAll(v, "\"", "\\\"") + v = strings.ReplaceAll(v, "\n", "\\n\" \\\n\"") + v = strings.ReplaceAll(v, "\r", "") + f.WriteString("#define " + k.Name + " \"" + v + "\"\n") + default: + return errors.New("Unsupported config value type: " + k.Type) + } + } + return nil +}
@@ -6,7 +6,6 @@ )
type Page struct { Title string - Slug string Content string Edited time.Time }
@@ -36,3 +36,32 @@ }
return http.HandlerFunc(handlerFunc) } + +func SanitizeFormMap(next http.Handler) http.Handler { + handlerFunc := func(w http.ResponseWriter, req *http.Request) { + delete(req.PostForm, "csrfToken") + next.ServeHTTP(w, req) + } + + return http.HandlerFunc(handlerFunc) +} + +func FormMapToAdapterConfig(next http.Handler, adapter core.Adapter) http.Handler { + handlerFunc := func(w http.ResponseWriter, req *http.Request) { + cfg := make(map[core.ConfigOption]string) + for k, arr := range req.PostForm { + v := strings.Join(arr, "") + optNameAndType := strings.Split(k, ":") + optName := optNameAndType[0] + optType := optNameAndType[1] + cfg[core.ConfigOption{ + Name: optName, + Type: optType, + }] = v + } + *req = *req.WithContext(context.WithValue(req.Context(), "config", cfg)) + next.ServeHTTP(w, req) + } + + return http.HandlerFunc(handlerFunc) +}
@@ -27,14 +27,18 @@ }
cfg.Adapter.Init(cfg) + pathConcat := filepath.Join + rtr := &router.Router{ StaticPaths: map[string]string{ - "/static": cfg.AssetRoot, + "/static": filepath.Join(cfg.AssetRoot, "static"), }, } + templateRoot := pathConcat(cfg.AssetRoot, "templates") + rtr.Get("/login", renderer.Template( - "templates/login.html")) + pathConcat(templateRoot, "login.html"))) rtr.Post("/login", middleware.Authorize("/", udb, "/login?tryagain=1"))@@ -45,9 +49,9 @@ "/",
middleware.Protected( shell.WithAdapter( renderer.Template( - "templates/cms_list.html", - "templates/header.html", - "templates/footer.html"), + pathConcat(templateRoot, "cms_list.html"), + pathConcat(templateRoot, "header.html"), + pathConcat(templateRoot, "footer.html")), cfg.Adapter), http.MethodGet, udb,@@ -59,9 +63,9 @@ middleware.Fortify(
middleware.Protected( shell.WithAdapter( renderer.Template( - "templates/cms_edit.html", - "templates/header.html", - "templates/footer.html"), + pathConcat(templateRoot, "cms_edit.html"), + pathConcat(templateRoot, "header.html"), + pathConcat(templateRoot, "footer.html")), cfg.Adapter), http.MethodGet, udb,@@ -74,9 +78,9 @@ middleware.Protected(
shell.WithAdapter( shell.EnsurePageData( renderer.Template( - "templates/cms_save.html", - "templates/header.html", - "templates/footer.html"), + pathConcat(templateRoot, "cms_save.html"), + pathConcat(templateRoot, "header.html"), + pathConcat(templateRoot, "footer.html")), cfg.Adapter), cfg.Adapter), http.MethodGet,@@ -91,9 +95,9 @@ middleware.Fortify(
middleware.Protected( shell.WithAdapter( renderer.Template( - "templates/cms_new.html", - "templates/header.html", - "templates/footer.html"), + pathConcat(templateRoot, "cms_new.html"), + pathConcat(templateRoot, "header.html"), + pathConcat(templateRoot, "footer.html")), cfg.Adapter), http.MethodGet, udb,@@ -106,9 +110,9 @@ middleware.Protected(
shell.WithAdapter( shell.EnsurePageData( renderer.Template( - "templates/cms_create.html", - "templates/header.html", - "templates/footer.html"), + pathConcat(templateRoot, "cms_create.html"), + pathConcat(templateRoot, "header.html"), + pathConcat(templateRoot, "footer.html")), cfg.Adapter), cfg.Adapter), http.MethodGet,@@ -123,9 +127,9 @@ middleware.Fortify(
middleware.Protected( shell.WithAdapter( renderer.Template( - "templates/build.html", - "templates/header.html", - "templates/footer.html"), + pathConcat(templateRoot, "build.html"), + pathConcat(templateRoot, "header.html"), + pathConcat(templateRoot, "footer.html")), cfg.Adapter), http.MethodGet, udb,@@ -135,12 +139,13 @@ rtr.Post(
`/build-run`, middleware.Defend( middleware.Protected( - shell.WithAdapter( - renderer.Template( - "templates/build_run.html", - "templates/header.html", - "templates/footer.html"), - cfg.Adapter), + shell.SanitizeFormMap( + shell.WithAdapter( + renderer.Template( + pathConcat(templateRoot, "build_run.html"), + pathConcat(templateRoot, "header.html"), + pathConcat(templateRoot, "footer.html")), + cfg.Adapter)), http.MethodGet, udb, "/login"),@@ -153,14 +158,48 @@ middleware.Defend(
middleware.Protected( shell.WithAdapter( renderer.Template( - "templates/delete.html", - "templates/header.html", - "templates/footer.html"), + pathConcat(templateRoot, "delete.html"), + pathConcat(templateRoot, "header.html"), + pathConcat(templateRoot, "footer.html")), cfg.Adapter), http.MethodGet, udb, "/login"), udb, "/")) + + rtr.Get( + `/config`, + middleware.Fortify( + middleware.Protected( + shell.WithAdapter( + renderer.Template( + pathConcat(templateRoot, "config.html"), + pathConcat(templateRoot, "header.html"), + pathConcat(templateRoot, "footer.html")), + cfg.Adapter), + http.MethodGet, + udb, + "/login"))) + + rtr.Post( + `/config-set`, + middleware.Defend( + middleware.Protected( + shell.SanitizeFormMap( + shell.FormMapToAdapterConfig( + shell.WithAdapter( + renderer.Template( + pathConcat(templateRoot, "config_set.html"), + pathConcat(templateRoot, "header.html"), + pathConcat(templateRoot, "footer.html")), + cfg.Adapter), + cfg.Adapter)), + http.MethodGet, + udb, + "/login"), + udb, + "/")) + http.ListenAndServe(":8080", rtr) }
@@ -1,7 +1,7 @@
body { padding: 0; margin: 0; - font-family: sans; + font-family: sans-serif; font-size: 16px; height: 100vh; background: black;@@ -37,7 +37,18 @@ text-transform: uppercase;
color: lightgray; } -.login form input { +.login label { + padding: 0; + margin: 0; + height: 0; + width: 0; + font-size: 0; + display: inline; + position: absolute; +} + + +.login form input, .login form input:-internal-autofill-selected { display: block; margin: 1em; margin-left: auto;@@ -52,6 +63,7 @@
.login form input[type="text"], login form input[type="password"] { transition: border 1s; outline: none; + margin-bottom: -17px; } .login form input:focus {@@ -61,6 +73,7 @@ }
.login form input[type="submit"] { + margin-top: -17px; text-transform: uppercase; transition: background 1s, color 1s; }@@ -155,7 +168,9 @@ position: sticky;
top: 3em; } -.page-list, form.editor, form.build, span.adapter-error, span.adapter-success, .danger-zone { +.page-list, form.editor, form.build, form.configurator, span.adapter-error, span.adapter-success, .danger-zone { + display: block; + overflow-x: hidden; width: 80%; max-width: 500px; background: rgba(0,0,0,0.8);@@ -166,13 +181,17 @@ max-height: calc(100vh - 20em);
overflow-y: auto; } -form.editor label, form.build label { +span.adapter-error { + border-bottom: 2px solid crimson; +} + +form.editor label, form.build label, .danger-zone label, form.configurator label { font-size: 80%; color: lightgray; text-transform: uppercase; } -form.editor input, form.build input, form.editor textarea { +form.editor input, form.build input, form.editor textarea, form.configurator input, form.configurator textarea, .danger-zone input[type="submit"] { display: block; margin: 0; margin-top: 0.2em;@@ -185,24 +204,42 @@ transition: border 1s;
outline: none; } -form.editor input.title-input { +form.editor input[type="text"], form.configurator input[type="text"], form.configurator input[type="number"] { margin: 0; width: 100%; + +} + +form.editor input.title-input { font-size: 150%; } +form input:focus, form textarea:focus { + border: 2px solid cyan; +} + form.editor, .danger-zone { max-width: 80em; } form.editor textarea { margin: 0; - width: 80em; + width: 100%; font-size: 16px; height: 25em; } -form.editor input[type="submit"], form.build input[type="submit"] { +form.configurator textarea { + marign: 0; + width: 100%; + height: 5em; +} + +form.configurator input, form.configurator textarea { + font-size: 125%; +} + +form.editor input[type="submit"], form.build input[type="submit"], .danger-zone input[type="submit"], form.configurator input[type="submit"] { margin-left: auto; margin-right: 0; font-size: 150%;@@ -210,7 +247,7 @@ text-transform: uppercase;
transition: background 1s, color 1s; } -form.editor input[type="submit"]:hover { +form.editor input[type="submit"]:hover,form.build input[type="submit"]:hover, .danger-zone input[type="submit"]:hover, form.configurator input[type="submit"]:hover { background: lightgray; color: black; }@@ -234,4 +271,4 @@ }
form input[hidden] { display: none; -}+}
@@ -0,0 +1,25 @@
+{{ $config := ((.Context).Value "adapter").GetConfig }} +{{ $csrfToken := (.Context).Value "csrfToken" }} + +{{ template "header" . }} + +<h2>Configuration</h2> + +<form class="configurator" method="POST" action="/config-set"> +<input hidden type="text" name="csrfToken" value="{{$csrfToken}}"/> + {{ range $opt, $val := $config }} + {{ if eq ($opt).Type "int" }} + <label>{{($opt).Name}} <input type="number" step="1" name="{{($opt).Name}}:{{($opt).Type}}" value="{{$val}}"/></label><br/> + {{ else if eq ($opt).Type "float" }} + <label>{{($opt).Name}} <input type="number" step="0.00000001" name="{{($opt).Name}}:{{($opt).Type}}" value="{{$val}}"/></label><br/> + {{ else if eq ($opt).Type "string" }} + <label>{{($opt).Name}} <input type="text" name="{{($opt).Name}}:{{($opt).Type}}" value="{{$val}}"/></label><br/> + {{ else if eq ($opt).Type "multilinestring" }} + <label>{{($opt).Name}} <textarea name="{{($opt).Name}}:{{($opt).Type}}">{{$val}}</textarea></label><br/> + {{ end}} + + {{ end }} +<input type="submit" value="Save"/> +</form> + +{{ template "footer" . }}
@@ -0,0 +1,14 @@
+{{ $config := (.Context).Value "config" }} +{{ $cfgError := ((.Context).Value "adapter").SetConfig $config }} + +{{ template "header" . }} + +{{ if $cfgError }} + <h2>Configuration Error</h2> + <span class="adapter-error">{{($cfgError).Error}}</span> +{{ else }} + <h2>Configuration Saved</h2> + <span class="adapter-success">The adapter configuration has been saved</span> +{{ end }} + +{{ template "footer" . }}
@@ -17,8 +17,10 @@ {{ if $tryagain }}
<span class="error">Incorrect credentials; please try again.</span> {{ end }} <form action='/login' method='post'> - <input type="text" name="user" placeholder="user"> - <input type="password" name="password" placeholder="password"> + <label for="user-input">Username</label> + <input type="text" id="user-input" name="user" placeholder="user"><br/> + <label for="password-input">Password</label> + <input type="password" id="password-input" name="password" placeholder="password"><br/> <input type="submit" value="Login"> </form> </div>