Put sources under src/.

This commit is contained in:
Catherine
2025-09-15 04:51:51 +00:00
parent 2f59de02e3
commit b9a26e528f
5 changed files with 1 additions and 0 deletions
+64
View File
@@ -0,0 +1,64 @@
package main
import (
"crypto/sha256"
"fmt"
"net"
"net/http"
"slices"
"strings"
)
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 authorize(w http.ResponseWriter, r *http.Request) error {
host := getHost(r)
authorization := r.Header.Get("Authorization")
if authorization == "" {
http.Error(w, "missing Authorization header", http.StatusUnauthorized)
return fmt.Errorf("missing Authorization header")
}
scheme, param, success := strings.Cut(authorization, " ")
if !success {
http.Error(w, "malformed Authorization header", http.StatusBadRequest)
return fmt.Errorf("malformed Authorization header")
}
if scheme != "Pages" {
http.Error(w, "unknown Authorization scheme", http.StatusBadRequest)
return fmt.Errorf("unknown Authorization scheme")
}
challengeHostname := fmt.Sprintf("_git-pages-challenge.%s", host)
actualChallenges, err := net.LookupTXT(challengeHostname)
if err != nil {
http.Error(w, "failed to look up DNS challenge", http.StatusUnauthorized)
return fmt.Errorf("failed to look up %s: %s", challengeHostname, err)
}
expectedChallenge := fmt.Sprintf("%x", sha256.Sum256(fmt.Appendf(nil, "%s %s", host, param)))
if !slices.Contains(actualChallenges, expectedChallenge) {
http.Error(w,
fmt.Sprintf("defeated by DNS challenge (%s not in %s)", expectedChallenge, challengeHostname),
http.StatusUnauthorized,
)
return fmt.Errorf(
"challenge mismatch for %s: %s does not contain %s",
challengeHostname,
actualChallenges,
expectedChallenge,
)
}
return nil
}
+177
View File
@@ -0,0 +1,177 @@
package main
import (
"errors"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"time"
"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 FetchOutcome int
const (
FetchError FetchOutcome = iota
FetchTimeout
FetchCreated
FetchUpdated
FetchNoChange
)
type FetchResult struct {
outcome FetchOutcome
head string
err error
}
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,
) FetchResult {
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 FetchResult{err: fmt.Errorf("git clone: %s", err)}
}
ref, err := repo.Head()
if err != nil {
return FetchResult{err: 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 FetchResult{err: fmt.Errorf("mkdir temp: %s", err)}
}
defer os.RemoveAll(tempDir)
repo, err = git.Open(storer, osfs.New(tempDir, osfs.WithBoundOS()))
if err != nil {
return FetchResult{err: fmt.Errorf("git open: %s", err)}
}
worktree, err := repo.Worktree()
if err != nil {
return FetchResult{err: fmt.Errorf("git worktree: %s", err)}
}
if err := worktree.Checkout(&git.CheckoutOptions{
Hash: head,
}); err != nil {
return FetchResult{err: fmt.Errorf("git checkout: %s", err)}
}
if err := os.MkdirAll(filepath.Dir(destDir), 0o755); err != nil {
return FetchResult{err: 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 FetchResult{err: 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 FetchResult{err: fmt.Errorf("symlink temp: %s", err)}
}
defer os.Remove(tempLink)
if err := os.MkdirAll(filepath.Dir(webLink), 0o755); err != nil {
return FetchResult{err: 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
outcome := FetchCreated
if existingLink, err := os.Readlink(webLink); err == nil {
if existingLink != destDirRel {
outcome = FetchUpdated
} else {
outcome = 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 FetchResult{err: fmt.Errorf("rename web: %s", err)}
}
return FetchResult{outcome: outcome, head: head.String(), err: nil}
}
func Fetch(
dataDir string,
webRoot string,
repoURL string,
branch string,
) FetchResult {
log.Println("fetch:", webRoot, repoURL, branch)
result := fetch(dataDir, webRoot, repoURL, branch)
if result.err == nil {
status := ""
switch result.outcome {
case FetchCreated:
status = "created"
case FetchUpdated:
status = "updated"
case FetchNoChange:
status = "unchanged"
}
log.Println("fetch ok:", webRoot, result.head, status)
} else {
log.Println("fetch err:", fmt.Errorf("%s: %s", webRoot, result.err))
}
return result
}
func FetchWithTimeout(
dataDir string,
webRoot string,
repoURL string,
branch string,
timeout time.Duration,
) FetchResult {
// fetch the updated content with a timeout
c := make(chan FetchResult, 1)
go func() {
result := Fetch(dataDir, webRoot, repoURL, branch)
c <- result
}()
select {
case result := <-c:
return result
case <-time.After(timeout):
return FetchResult{outcome: FetchTimeout, err: fmt.Errorf("fetch timeout")}
}
}
+18
View File
@@ -0,0 +1,18 @@
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)
}
}
+236
View File
@@ -0,0 +1,236 @@
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
securejoin "github.com/cyphar/filepath-securejoin"
"golang.org/x/sys/unix"
)
const fetchTimeout = 30 * time.Second
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
}
func getProjectName(w http.ResponseWriter, r *http.Request) (string, error) {
// 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")
}
if path == "" {
// path `/` corresponds to pseudo-project `.index`
return ".index", nil
} else {
return path, nil
}
}
func putPage(dataDir string, w http.ResponseWriter, r *http.Request) error {
host := getHost(r)
err := authorize(w, r)
if err != nil {
return err
}
projectName, err := getProjectName(w, r)
if err != nil {
return err
}
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"
}
result := FetchWithTimeout(dataDir, webRoot, repoURL, branch, fetchTimeout)
if result.err == nil {
w.Header().Add("Content-Location", r.URL.String())
}
switch result.outcome {
case FetchError:
w.WriteHeader(http.StatusServiceUnavailable)
case FetchTimeout:
w.WriteHeader(http.StatusGatewayTimeout)
// 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)
}
if result.err != nil {
fmt.Fprintln(w, result.err)
} else {
fmt.Fprintln(w, result.head)
}
return result.err
}
func postPage(dataDir string, w http.ResponseWriter, r *http.Request) error {
host := getHost(r)
err := authorize(w, r)
if err != nil {
return err
}
projectName, err := getProjectName(w, r)
if err != nil {
return err
}
if r.Header.Get("Content-Type") != "application/json" {
http.Error(w, "only JSON payload is allowed", http.StatusBadRequest)
return fmt.Errorf("invalid content type")
}
if r.Header.Get("X-Forgejo-Event") != "push" {
http.Error(w, "only push events are allowed", http.StatusBadRequest)
return fmt.Errorf("invalid event")
}
requestBody, err := io.ReadAll(r.Body)
if err != nil {
return fmt.Errorf("body read: %s", err)
}
var event map[string]any
err = json.NewDecoder(bytes.NewReader(requestBody)).Decode(&event)
if err != nil {
http.Error(w, fmt.Sprintf("invalid request body: %s", err), http.StatusBadRequest)
return err
}
eventRef := event["ref"].(string)
if eventRef != "refs/heads/pages" {
w.WriteHeader(http.StatusOK)
return nil
}
webRoot := fmt.Sprintf("%s/%s", host, projectName)
repoURL := event["repository"].(map[string]any)["clone_url"].(string)
result := FetchWithTimeout(dataDir, webRoot, repoURL, "pages", fetchTimeout)
switch result.outcome {
case FetchError:
w.WriteHeader(http.StatusServiceUnavailable)
case FetchTimeout:
w.WriteHeader(http.StatusGatewayTimeout)
default:
w.WriteHeader(http.StatusOK)
}
if result.err != nil {
fmt.Fprintln(w, result.err)
}
return result.err
}
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)
case http.MethodPost:
err = postPage(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)
}
}
}