all repos — nirvash @ 37d184b3e228e0b43aa54fb45275131f46540229

modular CMS using the quartzgun library

fix style, implement config load/save
Iris Lightshard nilix@nilfm.cc
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-----
commit

37d184b3e228e0b43aa54fb45275131f46540229

parent

8fdc9ddb460f6dc76363da7c64bdae15927bf430

M archetype/adapter.goarchetype/adapter.go

@@ -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
M archetype/eureka.goarchetype/eureka.go

@@ -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 +}
M archetype/page.goarchetype/page.go

@@ -6,7 +6,6 @@ )

type Page struct { Title string - Slug string Content string Edited time.Time }
M lfo/middleware.golfo/middleware.go

@@ -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) +}
M nirvash.gonirvash.go

@@ -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) }
M static/style.cssstatic/style.css

@@ -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; -}+}
A templates/config.html

@@ -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" . }}
A templates/config_set.html

@@ -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" . }}
M templates/login.htmltemplates/login.html

@@ -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>