First prototype

This commit is contained in:
akp 2023-08-02 21:46:04 +01:00
commit c145f66d3f
No known key found for this signature in database
GPG key ID: CF8D58F3DEB20755
16 changed files with 517 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
run

9
README.md Normal file
View file

@ -0,0 +1,9 @@
# Palmatum
*A selfhostable Github Pages-esque static site deployment thing*
---
## Namesake
[*Acer palmatum*](https://en.wikipedia.org/wiki/Acer_palmatum) is a species of maple tree. Trees are used to make paper, which is used to make pages in a book and Palmatum is a replacement for Github *Pages*.

17
go.mod Normal file
View file

@ -0,0 +1,17 @@
module github.com/codemicro/palmatum
go 1.20
require (
github.com/pkg/errors v0.9.1
github.com/rs/zerolog v1.30.0
)
require (
github.com/julienschmidt/httprouter v1.3.0 // indirect
github.com/mattn/go-colorable v0.1.12 // indirect
github.com/mattn/go-isatty v0.0.14 // indirect
golang.org/x/exp v0.0.0-20230801115018-d63ba01acd4b // indirect
golang.org/x/sys v0.1.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

23
go.sum Normal file
View file

@ -0,0 +1,23 @@
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.30.0 h1:SymVODrcRsaRaSInD9yQtKbtWqwsfoPcRff/oRXLj4c=
github.com/rs/zerolog v1.30.0/go.mod h1:/tk+P47gFdPXq4QYjvCmT5/Gsug2nagsFWBWhAiSi1w=
golang.org/x/exp v0.0.0-20230801115018-d63ba01acd4b h1:r+vk0EmXNmekl0S0BascoeeoHk/L7wmaW2QF90K+kYI=
golang.org/x/exp v0.0.0-20230801115018-d63ba01acd4b/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6 h1:foEbQz/B0Oz6YIqu/69kfXPYeFQAuuMYFkjaqXzl5Wo=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -0,0 +1,54 @@
package config
import (
"golang.org/x/exp/slog"
)
type HTTP struct {
Host string
Port int
}
type Database struct {
DSN string
}
type Platform struct {
SitesDirectory string
MaxUploadSizeMegabytes int
}
type Config struct {
Debug bool
HTTP *HTTP
Database *Database
Platform *Platform
}
func Load() (*Config, error) {
cl := new(configLoader)
if err := cl.load("config.yml"); err != nil {
return nil, err
}
conf := &Config{
Debug: asBool(cl.withDefault("debug", false)),
HTTP: &HTTP{
Host: asString(cl.withDefault("http.host", "127.0.0.1")),
Port: asInt(cl.withDefault("http.port", 8080)),
},
Database: &Database{
DSN: asString(cl.withDefault("database.dsn", "website.db")),
},
Platform: &Platform{
SitesDirectory: asString(cl.required("platform.sitesDirectory")),
MaxUploadSizeMegabytes: asInt(cl.withDefault("platform.maxUploadSizeMegabytes", 512)),
},
}
if conf.Debug {
slog.Debug("debug mode enabled")
}
return conf, nil
}

View file

@ -0,0 +1,106 @@
package config
import (
"fmt"
"golang.org/x/exp/slog"
"gopkg.in/yaml.v3"
"os"
"regexp"
"strconv"
"strings"
)
type configLoader struct {
rawConfigFileContents map[string]any
lastKey string
}
func (cl *configLoader) load(fname string) error {
cl.rawConfigFileContents = make(map[string]any)
fcont, err := os.ReadFile(fname)
if err != nil {
slog.Warn("cannot load config file", "filename", fname)
return nil
}
if err := yaml.Unmarshal(fcont, &cl.rawConfigFileContents); err != nil {
return fmt.Errorf("unmarshaling config file: %w", err)
}
return nil
}
type optionalItem struct {
item any
found bool
}
var indexedPartRegexp = regexp.MustCompile(`(?m)([a-zA-Z]+)(?:\[(\d+)\])?`)
func (cl *configLoader) get(key string) optionalItem {
// httpcore[2].bananas
cl.lastKey = key
parts := strings.Split(key, ".")
var cursor any = cl.rawConfigFileContents
for _, part := range parts {
components := indexedPartRegexp.FindStringSubmatch(part)
key := components[1]
index, _ := strconv.ParseInt(components[2], 10, 32)
isIndexed := components[2] != ""
item, found := cursor.(map[string]any)[key]
if !found {
return optionalItem{nil, false}
}
if isIndexed {
arr, conversionOk := item.([]any)
if !conversionOk {
slog.Error(fmt.Sprintf("attempted to index non-indexable config item %s", key))
os.Exit(1)
}
cursor = arr[index]
} else {
cursor = item
}
}
return optionalItem{cursor, true}
}
func (cl *configLoader) required(key string) optionalItem {
opt := cl.get(key)
if !opt.found {
slog.Error(fmt.Sprintf("required key %s not found in config file", key))
os.Exit(1)
}
return opt
}
func (cl *configLoader) withDefault(key string, defaultValue any) optionalItem {
opt := cl.get(key)
if !opt.found {
return optionalItem{item: defaultValue, found: true}
}
return opt
}
func asInt(x optionalItem) int {
if !x.found {
return 0
}
return x.item.(int)
}
func asString(x optionalItem) string {
if !x.found {
return ""
}
return x.item.(string)
}
func asBool(x optionalItem) bool {
if !x.found {
return false
}
return x.item.(bool)
}

View file

@ -0,0 +1,53 @@
package httpsrv
import (
"github.com/codemicro/rubrum/rubrum/internal/config"
"github.com/julienschmidt/httprouter"
"net/http"
"strings"
)
func New(conf *config.Config) (http.Handler, error) {
r := &routes{
config: conf,
}
router := httprouter.New()
router.GET("/-/", r.managementIndex)
router.POST("/-/upload", r.uploadSite)
router.POST("/-/delete", r.deleteSite)
return router, nil
}
type routes struct {
config *config.Config
}
func BadRequestResponse(w http.ResponseWriter, message ...string) error {
outputMessage := "Bad Request"
if len(message) != 0 {
outputMessage = message[0]
}
w.WriteHeader(400)
_, err := w.Write([]byte(outputMessage))
return err
}
func IsBrowser(r *http.Request) bool {
if r.Header.Get("HX-Request") != "" {
return true
}
sp := strings.Split(r.Header.Get("Accept"), ",")
for _, item := range sp {
if item == "" {
continue
}
x := strings.Split(item, ";")
if strings.EqualFold(x[0], "text/html") {
return true
}
}
return false
}

View file

@ -0,0 +1,168 @@
package httpsrv
import (
"archive/zip"
_ "embed"
"errors"
"fmt"
"github.com/julienschmidt/httprouter"
"html/template"
"io"
"net/http"
"os"
"path"
"regexp"
)
//go:embed managementIndex.html
var managementPageTemplateSource string
var managementPageTemplate *template.Template
func init() {
managementPageTemplate = template.New("management page")
template.Must(managementPageTemplate.Parse(managementPageTemplateSource))
}
func (ro *routes) managementIndex(w http.ResponseWriter, _ *http.Request, _ httprouter.Params) {
dirEntries, err := os.ReadDir(ro.config.Platform.SitesDirectory)
if err != nil {
panic(fmt.Errorf("reading sites directory contents: %w", err))
}
var sites []string
for _, de := range dirEntries {
sites = append(sites, de.Name())
}
var templateArgs = struct {
ActiveSites []string
}{
ActiveSites: sites,
}
w.Header().Set("Content-Type", "text/html")
if err := managementPageTemplate.Execute(w, templateArgs); err != nil {
panic(fmt.Errorf("rendering management page: %w", err))
}
}
var siteNameValidationRegexp = regexp.MustCompile(`^([\w\-.~!$&'()*+,;=:@]{2,})|(-[\w\-.~!$&'()*+,;=:@]+)$`)
func (ro *routes) uploadSite(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
siteName := r.FormValue("siteName")
if siteName == "" {
_ = BadRequestResponse(w, "missing site name")
return
}
if !siteNameValidationRegexp.MatchString(siteName) {
_ = BadRequestResponse(w, "invalid site name")
return
}
formFile, formFileHeader, err := r.FormFile("archive")
if err != nil {
if errors.Is(err, http.ErrMissingFile) {
_ = BadRequestResponse(w, "missing archive file")
return
}
if err.Error() == "request Content-Type isn't multipart/form-data" {
_ = BadRequestResponse(w, "request Content-Type isn't multipart/form-data")
return
}
panic(fmt.Errorf("loading archive request parameter: %w", err))
}
if formFileHeader.Size > 1000*1000*int64(ro.config.Platform.MaxUploadSizeMegabytes) {
_ = BadRequestResponse(w, fmt.Sprintf("archive too large (maximum size %dMB)", ro.config.Platform.MaxUploadSizeMegabytes))
return
}
zipfileReader, err := zip.NewReader(formFile, formFileHeader.Size)
if err != nil {
panic(fmt.Errorf("creating zip file reader: %w", err))
}
// We extract the zip file to a temporary location first as to not end up with pages being served with a mix of old
// and new content while zip file extraction is taking place.
// Create temporary directory
tempDir, err := os.MkdirTemp(ro.config.Platform.SitesDirectory, "")
if err != nil {
panic(fmt.Errorf("creating temporary directory: %w", err))
}
defer os.RemoveAll(tempDir) // by the time this runs we should have copied the directory to its final place but this
// Extract ZIP file contents
// 1st iteration: create directories (files where name ends in a `/`)
// 2nd iteration: copy files
for _, file := range zipfileReader.File {
if file.Name[len(file.Name)-1] != '/' {
continue
}
newPath := path.Join(tempDir, file.Name)
err := os.Mkdir(newPath, 0777)
if err != nil {
panic(fmt.Errorf("creating directory %s during archive unpacking: %w", newPath, err))
}
}
for _, file := range zipfileReader.File {
if file.Name[len(file.Name)-1] == '/' {
continue
}
newPath := path.Join(tempDir, file.Name)
fh, err := os.OpenFile(newPath, os.O_WRONLY|os.O_CREATE, 0777)
if err != nil {
panic(fmt.Errorf("creating file %s during archive unpacking: %w", newPath, err))
}
zfh, err := file.Open()
if err != nil {
panic(fmt.Errorf("opening archive file %s during unpacking: %w", file.Name, err))
}
if _, err := io.Copy(fh, zfh); err != nil {
panic(fmt.Errorf("copying file data to %s during archive unpacking: %w", newPath, err))
}
if err := fh.Close(); err != nil {
panic(fmt.Errorf("closing %s during archive unpacking: %w", newPath, err))
}
if err := zfh.Close(); err != nil {
panic(fmt.Errorf("closing zip file %s during archive unpacking: %w", file.Name, err))
}
}
// Delete old directory (if applicable)
permPath := path.Join(ro.config.Platform.SitesDirectory, siteName)
_ = os.RemoveAll(permPath)
// Move temporary directory to new directory
if err := os.Rename(tempDir, permPath); err != nil {
panic(fmt.Errorf("renaming temporary directory: %w", err))
}
if IsBrowser(r) {
w.Header().Add("Location", "/-/")
w.WriteHeader(303) // 303 See Other - resets the method to GET
} else {
w.WriteHeader(204)
}
}
func (ro *routes) deleteSite(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
siteName := r.FormValue("siteName")
if !siteNameValidationRegexp.MatchString(siteName) {
_ = BadRequestResponse(w, "invalid site name")
return
}
_ = os.RemoveAll(path.Join(ro.config.Platform.SitesDirectory, siteName))
w.Header().Set("HX-Refresh", "true")
w.WriteHeader(204)
}

View file

@ -0,0 +1,26 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html;charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Management portal</title>
<script src="https://unpkg.com/htmx.org@1.9.4" defer></script>
</head>
<body>
<h1>Palmatum Management Portal</h1>
<h2>Active sites</h2>
<ul>
{{ range $name := .ActiveSites }}<li><a href="/{{ $name }}">{{ $name }}</a> <a href="" hx-post="/-/delete" hx-vals='{"siteName": "{{ $name }}"}' hx-confirm="Are you sure you want to delete '{{ $name }}'?">[x]</a></li>{{ else }}<li><i>No active sites</i></li>{{ end }}
</ul>
<h2>Upload new site bundle</h2>
<form action="/-/upload" method="post" enctype="multipart/form-data">
<label for="siteNameInput">Site name/path: </label>
<input type="text" name="siteName" id="siteNameInput">
<br>
<label for="archiveInput">Site archive: </label>
<input type="file" name="archive" id="archiveInput">
<br>
<input type="submit" value="Submit">
</form>
</body>
</html>

40
palmatum/main.go Normal file
View file

@ -0,0 +1,40 @@
package main
import (
"fmt"
"github.com/codemicro/rubrum/rubrum/internal/config"
"github.com/codemicro/rubrum/rubrum/internal/httpsrv"
"golang.org/x/exp/slog"
"net/http"
"os"
)
func main() {
if err := run(); err != nil {
return
}
}
func run() error {
conf, err := config.Load()
if err != nil {
return fmt.Errorf("load config on startup: %w", err)
}
_ = os.MkdirAll(conf.Platform.SitesDirectory, 0777)
handler, err := httpsrv.New(conf)
if err != nil {
return fmt.Errorf("creating HTTP handler: %w", err)
}
host := fmt.Sprintf("%s:%d", conf.HTTP.Host, conf.HTTP.Port)
slog.Info("http server alive", "host", host)
err = http.ListenAndServe(host, handler)
if err != nil {
return fmt.Errorf("serving HTTP: %w", err)
}
return nil
}

BIN
testdata/site-a.zip vendored Normal file

Binary file not shown.

7
testdata/site-a/index.html vendored Normal file
View file

@ -0,0 +1,7 @@
<!DOCTYPE html>
<html>
<body style="background-color: red; color: white;">
<h1>This is site 1</h1>
<a href="page2.html">:eyes: what's this?</a>
</body>
</html>

6
testdata/site-a/page2.html vendored Normal file
View file

@ -0,0 +1,6 @@
<!DOCTYPE html>
<html>
<body style="background-color: red; color: white;">
<h1>This is a different page on site 1</h1>
</body>
</html>

BIN
testdata/site-b.zip vendored Normal file

Binary file not shown.

BIN
testdata/site-b/img/paralol.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

7
testdata/site-b/index.html vendored Normal file
View file

@ -0,0 +1,7 @@
<!DOCTYPE html>
<html>
<body style="background-color: limegreen">
<h1>This is site 2</h1>
<img src="img/paralol.jpg">
</body>
</html>