First prototype
This commit is contained in:
commit
c145f66d3f
16 changed files with 517 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
run
|
9
README.md
Normal file
9
README.md
Normal 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
17
go.mod
Normal 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
23
go.sum
Normal 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=
|
54
palmatum/internal/config/config.go
Normal file
54
palmatum/internal/config/config.go
Normal 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
|
||||
}
|
106
palmatum/internal/config/file.go
Normal file
106
palmatum/internal/config/file.go
Normal 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)
|
||||
}
|
53
palmatum/internal/httpsrv/http.go
Normal file
53
palmatum/internal/httpsrv/http.go
Normal 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
|
||||
}
|
168
palmatum/internal/httpsrv/management.go
Normal file
168
palmatum/internal/httpsrv/management.go
Normal 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)
|
||||
}
|
26
palmatum/internal/httpsrv/managementIndex.html
Normal file
26
palmatum/internal/httpsrv/managementIndex.html
Normal 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
40
palmatum/main.go
Normal 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
BIN
testdata/site-a.zip
vendored
Normal file
Binary file not shown.
7
testdata/site-a/index.html
vendored
Normal file
7
testdata/site-a/index.html
vendored
Normal 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
6
testdata/site-a/page2.html
vendored
Normal 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
BIN
testdata/site-b.zip
vendored
Normal file
Binary file not shown.
BIN
testdata/site-b/img/paralol.jpg
vendored
Normal file
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
7
testdata/site-b/index.html
vendored
Normal 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>
|
Loading…
Add table
Add a link
Reference in a new issue