Reorganize, add README and LICENSE.

This commit is contained in:
Catherine
2025-09-05 08:56:35 +00:00
parent 81d795923f
commit 61b226c1f2
7 changed files with 88 additions and 1 deletions
-149
View File
@@ -1,149 +0,0 @@
package main
import (
"errors"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"github.com/go-git/go-billy/v6/osfs"
"github.com/go-git/go-git/v6"
"github.com/go-git/go-git/v6/plumbing"
"github.com/go-git/go-git/v6/storage/memory"
)
type FetchResult int
const (
FetchError FetchResult = iota
FetchCreated
FetchUpdated
FetchNoChange
)
func splitHash(hash plumbing.Hash) string {
head := hash.String()
return filepath.Join(head[:2], head[2:])
}
func fetch(
dataDir string,
webRoot string,
repoURL string,
branch string,
) (*plumbing.Hash, FetchResult, error) {
storer := memory.NewStorage()
repo, err := git.Clone(storer, nil, &git.CloneOptions{
URL: repoURL,
ReferenceName: plumbing.ReferenceName(branch),
SingleBranch: true,
Depth: 1,
Tags: git.NoTags,
})
if err != nil {
return nil, 0, fmt.Errorf("git clone: %s", err)
}
ref, err := repo.Head()
if err != nil {
return nil, 0, fmt.Errorf("git head: %s", err)
}
head := ref.Hash()
destDir := filepath.Join(dataDir, "tree", splitHash(head))
if _, err := os.Stat(destDir); errors.Is(err, os.ErrNotExist) {
// check out to a temporary directory to avoid TOCTTOU race on destDir
tempDir, err := os.MkdirTemp(dataDir, ".tree")
if err != nil {
return nil, 0, fmt.Errorf("mkdir temp: %s", err)
}
defer os.RemoveAll(tempDir)
repo, err = git.Open(storer, osfs.New(tempDir))
if err != nil {
return nil, 0, fmt.Errorf("git open: %s", err)
}
worktree, err := repo.Worktree()
if err != nil {
return nil, 0, fmt.Errorf("git worktree: %s", err)
}
if err := worktree.Checkout(&git.CheckoutOptions{
Hash: head,
}); err != nil {
return nil, 0, fmt.Errorf("git checkout: %s", err)
}
if err := os.MkdirAll(filepath.Dir(destDir), 0o755); err != nil {
return nil, 0, fmt.Errorf("mkdir parent dest: %s", err)
}
// commit atomically; assume another fetch has won the race if directory exists
if err := os.Rename(tempDir, destDir); err != nil && !errors.Is(err, os.ErrExist) {
return nil, 0, fmt.Errorf("rename dest: %s", err)
}
}
webLink := filepath.Join(dataDir, "www", webRoot)
destDirRel, _ := filepath.Rel(filepath.Dir(webLink), destDir)
tempLink := filepath.Join(dataDir,
fmt.Sprintf(".link.%s.%s", strings.ReplaceAll(webRoot, "/", ".."), head.String()))
if err := os.Symlink(destDirRel, tempLink); err != nil {
return nil, 0, fmt.Errorf("symlink temp: %s", err)
}
defer os.Remove(tempLink)
if err := os.MkdirAll(filepath.Dir(webLink), 0o755); err != nil {
return nil, 0, fmt.Errorf("mkdir parent web: %s", err)
}
// this status is advisory only (is subject to race conditions); it's used only
// to return the correct HTTP status per the spec
fetchResult := FetchCreated
if existingLink, err := os.Readlink(webLink); err == nil {
if existingLink != destDirRel {
fetchResult = FetchUpdated
} else {
fetchResult = FetchNoChange
}
}
// commit atomically; assume another fetch has won the race if symlink exists
// FIXME: might not have the same target
if err := os.Rename(tempLink, webLink); err != nil && !errors.Is(err, os.ErrExist) {
return nil, 0, fmt.Errorf("rename web: %s", err)
}
return &head, fetchResult, nil
}
func Fetch(
dataDir string,
webRoot string,
repoURL string,
branch string,
) (string, FetchResult, error) {
log.Println("fetch:", webRoot, repoURL, branch)
head, result, err := fetch(dataDir, webRoot, repoURL, branch)
if err == nil {
status := ""
switch result {
case FetchCreated:
status = "created"
case FetchUpdated:
status = "updated"
case FetchNoChange:
status = "unchanged"
}
log.Println("fetch ok:", webRoot, head, status)
return head.String(), result, err
} else {
log.Println("fetch err:", fmt.Errorf("%s: %s", webRoot, err))
return "", FetchError, err
}
}
-28
View File
@@ -1,28 +0,0 @@
module whitequark.org/git-pages
go 1.24.0
toolchain go1.24.4
require (
github.com/cyphar/filepath-securejoin v0.4.1
github.com/go-git/go-billy/v6 v6.0.0-20250902094905-c2c3cf4b2510
github.com/go-git/go-git/v6 v6.0.0-20250831162718-34f273445e00
golang.org/x/sys v0.35.0
)
require (
dario.cat/mergo v1.0.2 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/cloudflare/circl v1.6.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/go-git/gcfg/v2 v2.0.2 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/kevinburke/ssh_config v1.4.0 // indirect
github.com/pjbgf/sha1cd v0.4.0 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
golang.org/x/crypto v0.41.0 // indirect
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b // indirect
golang.org/x/net v0.43.0 // indirect
)
-66
View File
@@ -1,66 +0,0 @@
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo=
github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs=
github.com/go-git/go-billy/v6 v6.0.0-20250902094905-c2c3cf4b2510 h1:OENVwI63hXDi8Lg8xzP+at+04zlRSG/JZMPxLy44c40=
github.com/go-git/go-billy/v6 v6.0.0-20250902094905-c2c3cf4b2510/go.mod h1:lKJxR4cJDv25TFfQTQ0zXWrKjd48IuGzNPqL7duMEQA=
github.com/go-git/go-git-fixtures/v5 v5.1.0 h1:b8cWxDLTk0s09Ihm9x1HvNGUzxUVlRwIH7EAM0gGDKg=
github.com/go-git/go-git-fixtures/v5 v5.1.0/go.mod h1:CdmU0oQeDuy4Xh8V0i9Ym+vsTkgDDPKEiofBFEVT+aE=
github.com/go-git/go-git/v6 v6.0.0-20250831162718-34f273445e00 h1:eW0gxk9rk3jv7mf4r+sKNLXNgex2LMReedRCRJewQhw=
github.com/go-git/go-git/v6 v6.0.0-20250831162718-34f273445e00/go.mod h1:O7tkz+vcaOSOSRqAGC+MG6evNI8NsTmyH98ey4BTYwk=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ=
github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/pjbgf/sha1cd v0.4.0 h1:NXzbL1RvjTUi6kgYZCX3fPwwl27Q1LJndxtUDVfJGRY=
github.com/pjbgf/sha1cd v0.4.0/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0=
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-18
View File
@@ -1,18 +0,0 @@
package main
import (
"log"
"net/http"
"os"
)
func main() {
dataDir := os.Args[1]
listenAddr := os.Args[2]
http.HandleFunc("/", Serve(dataDir))
err := http.ListenAndServe(listenAddr, nil)
if err != nil {
log.Fatalln("failed to listen:", err)
}
}
-185
View File
@@ -1,185 +0,0 @@
package main
import (
"bytes"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"time"
securejoin "github.com/cyphar/filepath-securejoin"
"golang.org/x/sys/unix"
)
const fetchTimeout = 30 * time.Second
func getHost(r *http.Request) string {
// FIXME: handle IDNA
host, _, err := net.SplitHostPort(r.Host)
if err != nil {
// dirty but the go stdlib doesn't have a "split port if present" function
host = r.Host
}
return host
}
func getPage(dataDir string, w http.ResponseWriter, r *http.Request) error {
host := getHost(r)
// if the first directory of the path exists under `www/$host`, use it as the root,
// else use `www/$host/.index`
path, _ := strings.CutPrefix(r.URL.Path, "/")
wwwRoot := filepath.Join("www", host, ".index")
requestPath := path
if projectName, projectPath, found := strings.Cut(path, "/"); found {
projectRoot := filepath.Join("www", host, projectName)
if file, _ := securejoin.OpenInRoot(dataDir, projectRoot); file != nil {
file.Close()
wwwRoot, requestPath = projectRoot, projectPath
}
}
// try to serve `$root/$path` first
file, err := securejoin.OpenInRoot(dataDir, filepath.Join(wwwRoot, requestPath))
if err == nil {
// if it's a directory, serve `$root/$path/index.html`
stat, statErr := file.Stat()
if statErr == nil && stat.IsDir() {
defer file.Close()
file, err = securejoin.OpenInRoot(dataDir,
filepath.Join(wwwRoot, requestPath, "index.html"))
}
}
// if whatever we were serving doesn't exist, try to serve `$root/404.html`
if errors.Is(err, os.ErrNotExist) {
file, _ = securejoin.OpenInRoot(dataDir, filepath.Join(wwwRoot, "404.html"))
}
// acquire read capability to the file being served (if possible)
reader := io.ReadSeeker(nil)
if file != nil {
defer file.Close()
file, err = securejoin.Reopen(file, unix.O_RDONLY)
if file != nil {
defer file.Close()
reader = file
}
}
// decide on the HTTP status
if err != nil {
if errors.Is(err, os.ErrNotExist) {
w.WriteHeader(http.StatusNotFound)
if reader == nil {
reader = bytes.NewReader([]byte("not found\n"))
}
} else {
w.WriteHeader(http.StatusInternalServerError)
reader = bytes.NewReader([]byte("internal server error\n"))
}
// serve custom 404 page (if any)
io.Copy(w, reader)
} else {
stat, _ := file.Stat()
http.ServeContent(w, r, path, stat.ModTime(), reader)
}
return err
}
type putResult struct {
head string
result FetchResult
err error
}
func putPage(dataDir string, w http.ResponseWriter, r *http.Request) error {
host := getHost(r)
// path must be either `/` or `/foo/` (`/foo` is accepted as an alias)
path, _ := strings.CutPrefix(r.URL.Path, "/")
path, _ = strings.CutSuffix(path, "/")
if strings.HasPrefix(path, ".") {
http.Error(w, "this directory name is reserved for system use", http.StatusBadRequest)
return fmt.Errorf("reserved name")
} else if strings.Contains(path, "/") {
http.Error(w, "only one level of nesting is allowed", http.StatusBadRequest)
return fmt.Errorf("nesting too deep")
}
// path `/` corresponds to pseudo-project `.index`
projectName := ".index"
if path != "" {
projectName = path
}
requestBody, err := io.ReadAll(r.Body)
if err != nil {
return fmt.Errorf("body read: %s", err)
}
// request body contains git repository URL `https://codeberg.org/...`
// request header X-Pages-Branch contains git branch, `pages` by default
webRoot := fmt.Sprintf("%s/%s", host, projectName)
repoURL := string(requestBody)
branch := r.Header.Get("X-Pages-Branch")
if branch == "" {
branch = "pages"
}
// fetch the updated content with a timeout
c := make(chan putResult, 1)
go func() {
head, result, err := Fetch(dataDir, webRoot, repoURL, branch)
c <- putResult{head, result, err}
}()
select {
case putResult := <-c:
if putResult.err == nil {
w.Header().Add("Content-Location", r.URL.String())
}
switch putResult.result {
case FetchError:
w.WriteHeader(http.StatusServiceUnavailable)
fmt.Fprintln(w, putResult.err)
return putResult.err
// HTTP prescribes these response codes to be used
case FetchNoChange:
w.WriteHeader(http.StatusNoContent)
case FetchCreated:
w.WriteHeader(http.StatusCreated)
case FetchUpdated:
w.WriteHeader(http.StatusOK)
}
fmt.Fprintln(w, putResult.head)
return nil
case <-time.After(fetchTimeout):
w.WriteHeader(http.StatusGatewayTimeout)
return fmt.Errorf("fetch timeout")
}
}
func Serve(dataDir string) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
log.Println("serve:", r.Method, r.Host, r.URL)
err := error(nil)
switch r.Method {
case http.MethodGet:
err = getPage(dataDir, w, r)
case http.MethodPut:
err = putPage(dataDir, w, r)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
err = fmt.Errorf("method %s not allowed", r.Method)
}
if err != nil {
log.Println("serve err:", err)
}
}
}