clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat/ui-updates
tinyrelay / tinygit / http.go
http.go 10.9K
1package tinygit23import (4 "bufio"5 "bytes"6 "compress/gzip"7 "context"8 "errors"9 "fmt"10 "html"11 "io"12 "log/slog"13 "net/http"14 "net/url"15 "os"16 "os/exec"17 "path/filepath"18 "strconv"19 "strings"20 "time"21)2223// ServeHTTP exposes Git smart HTTP at /npub/<identifier>.git and /prs/... .24// The subprocess boundary keeps pack parsing and object integrity in Git.25func (g *GitRelay) ServeHTTP(w http.ResponseWriter, req *http.Request) {26 w.Header().Set("Access-Control-Allow-Origin", "*")27 w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, POST, OPTIONS")28 w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Git-Protocol, Authorization")29 if req.Method == http.MethodOptions {30 w.WriteHeader(http.StatusNoContent)31 return32 }33 owner, id, suffix, alternative, ok := parsePathMode(req.URL.Path)34 if !ok {35 http.NotFound(w, req)36 return37 }38 r, err := g.lookup(owner, id)39 if alternative {40 g.mu.RLock()41 candidate, found := g.repos[key(owner, id)]42 g.mu.RUnlock()43 if found && candidate.Alternative {44 r, err = candidate, nil45 }46 }47 if err != nil {48 http.NotFound(w, req)49 return50 }51 if r.Private {52 // Private authorization may replace the body with a verified disk53 // spool. Close that replacement as well as the server-owned body.54 defer func() { _ = req.Body.Close() }()55 if g.authorizeHTTP == nil {56 http.Error(w, "private repository requires authorization", http.StatusForbidden)57 return58 }59 if err := g.authorizeHTTP(req.Context(), req, r); err != nil {60 http.Error(w, err.Error(), http.StatusForbidden)61 return62 }63 }64 if err := g.ensureRepo(r); err != nil {65 http.Error(w, err.Error(), 500)66 return67 }68 if suffix == "" && (req.Method == http.MethodGet || req.Method == http.MethodHead) {69 w.Header().Set("Content-Type", "text/html; charset=utf-8")70 _, _ = io.WriteString(w, "<html><body><code>"+html.EscapeString(r.Owner+"/"+r.Identifier)+"</code></body></html>\n")71 return72 }73 if err := g.cgi(req, w, r, suffix); err != nil {74 var streamed *cgiStreamError75 if errors.As(err, &streamed) {76 slog.ErrorContext(req.Context(), "Git response stream failed", "error", err)77 // Close an incomplete pack, rather than mark it complete or append78 // an HTTP error page to Git's binary protocol.79 panic(http.ErrAbortHandler)80 }81 http.Error(w, err.Error(), 500)82 return83 }84 if req.Method == http.MethodPost {85 // The CGI response has already been committed. Promotion callbacks are86 // retried by the next scheduler pass; never corrupt a successful Git87 // receive-pack response with a late HTTP error.88 _ = g.PromotePending(req.Context(), r)89 }90}9192func parsePath(path string) (owner, id, suffix string, ok bool) {93 owner, id, suffix, _, ok = parsePathMode(path)94 return95}9697func parsePathMode(path string) (owner, id, suffix string, alternative, ok bool) {98 var err error99 parts := strings.Split(strings.Trim(path, "/"), "/")100 if len(parts) < 2 {101 return102 }103 if parts[0] == "prs" {104 if len(parts) < 3 {105 return106 }107 parts = parts[1:]108 alternative = true109 }110 if len(parts) > 0 && parts[0] == "npub" {111 parts = parts[1:]112 }113 if len(parts) < 2 {114 return115 }116 if !strings.HasPrefix(parts[0], "npub1") || !strings.HasSuffix(parts[1], ".git") {117 return118 }119 owner, err = decodeNPub(parts[0])120 if err != nil {121 return122 }123 id, err = url.PathUnescape(strings.TrimSuffix(parts[1], ".git"))124 if err != nil || !validIdentifier(id) {125 return126 }127 if len(parts) > 2 {128 suffix = "/" + strings.Join(parts[2:], "/")129 }130 ok = true131 return132}133134const bech32Charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"135136func decodeNPub(s string) (string, error) {137 s = strings.ToLower(s)138 if !strings.HasPrefix(s, "npub1") {139 return "", errors.New("not an npub")140 }141 // Bech32 uses the first separator; the payload may itself contain the142 // character "1".143 pos := strings.IndexByte(s, '1')144 if pos < len("npub") || len(s)-pos < 7 {145 return "", errors.New("invalid npub")146 }147 data := make([]byte, 0, len(s)-pos-1)148 for _, c := range s[pos+1:] {149 i := strings.IndexRune(bech32Charset, c)150 if i < 0 {151 return "", errors.New("invalid npub character")152 }153 data = append(data, byte(i))154 }155 if bech32Polymod(append(hrpExpand(s[:pos]), data...)) != 1 {156 return "", errors.New("invalid npub checksum")157 }158 payload, err := convertBits(data[:len(data)-6], 5, 8, false)159 if err != nil || len(payload) != 32 {160 return "", errors.New("invalid npub payload")161 }162 return fmt.Sprintf("%x", payload), nil163}164165func hrpExpand(s string) []byte {166 out := make([]byte, 0, len(s)*2+1)167 for _, c := range s {168 out = append(out, byte(c>>5))169 }170 out = append(out, 0)171 for _, c := range s {172 out = append(out, byte(c&31))173 }174 return out175}176func bech32Polymod(v []byte) uint64 {177 const g0 = 0x3b6a57b2178 const g1 = 0x26508e6d179 const g2 = 0x1ea119fa180 const g3 = 0x3d4233dd181 const g4 = 0x2a1462b3182 chk := uint64(1)183 for _, x := range v {184 top := chk >> 25185 chk = (chk&0x1ffffff)<<5 | uint64(x)186 if top&1 != 0 {187 chk ^= g0188 }189 if top&2 != 0 {190 chk ^= g1191 }192 if top&4 != 0 {193 chk ^= g2194 }195 if top&8 != 0 {196 chk ^= g3197 }198 if top&16 != 0 {199 chk ^= g4200 }201 }202 return chk203}204func convertBits(data []byte, from, to uint, pad bool) ([]byte, error) {205 acc := uint(0)206 bits := uint(0)207 maxv := uint((1 << to) - 1)208 maxacc := uint((1 << (from + to - 1)) - 1)209 out := make([]byte, 0)210 for _, v := range data {211 if uint(v)>>from != 0 {212 return nil, errors.New("invalid bech32 data")213 }214 acc = (acc<<from | uint(v)) & maxacc215 bits += from216 for bits >= to {217 bits -= to218 out = append(out, byte((acc>>bits)&maxv))219 }220 }221 if pad {222 if bits > 0 {223 out = append(out, byte((acc<<(to-bits))&maxv))224 }225 } else if bits >= from || byte((acc<<(to-bits))&maxv) != 0 {226 return nil, errors.New("invalid bech32 padding")227 }228 return out, nil229}230231func (g *GitRelay) cgi(req *http.Request, w http.ResponseWriter, r Repository, suffix string) error {232 prefix := ""233 if r.Alternative {234 prefix = "/prs"235 }236 pathInfo := prefix + "/" + r.Owner + "/" + r.Identifier + ".git" + suffix237 ctx, cancel := context.WithCancel(req.Context())238 defer cancel()239 // Browser clients such as gitworkshop.dev fetch one commit at a time with240 // a filter, and refuse servers that do not advertise filter and241 // sha1-in-want. The -c settings reach upload-pack through the environment.242 cmd := exec.CommandContext(ctx, "git", "-c", "uploadpack.allowFilter=true", "-c", "uploadpack.allowAnySHA1InWant=true", "http-backend")243 cmd.Dir = g.root244 cmd.WaitDelay = 5 * time.Second245 stdin, contentLength, cleanup, err := cgiInput(req)246 if err != nil {247 return err248 }249 defer cleanup()250 cmd.Stdin = stdin251 var stderr cgiDiagnostic252 cmd.Stderr = &stderr253 stdout, err := cmd.StdoutPipe()254 if err != nil {255 return err256 }257 defer stdout.Close()258 query := req.URL.RawQuery259 env := os.Environ()260 env = append(env, "GIT_PROJECT_ROOT="+g.root, "GIT_HTTP_EXPORT_ALL=1", "PATH_INFO="+pathInfo, "PATH_TRANSLATED="+filepath.Join(g.root, strings.TrimPrefix(pathInfo, "/")), "REQUEST_METHOD="+req.Method, "QUERY_STRING="+query, "REMOTE_ADDR="+req.RemoteAddr)261 if value := req.Header.Get("Content-Type"); value != "" {262 env = append(env, "CONTENT_TYPE="+value)263 }264 if contentLength != "" {265 env = append(env, "CONTENT_LENGTH="+contentLength)266 }267 if value := req.Header.Get("Git-Protocol"); value != "" {268 env = append(env, "HTTP_GIT_PROTOCOL="+value)269 }270 cmd.Env = env271 if err := cmd.Start(); err != nil {272 return fmt.Errorf("start git http-backend: %w", err)273 }274 controller := http.NewResponseController(w)275 _ = controller.EnableFullDuplex()276 reader := bufio.NewReader(stdout)277 started, streamErr := streamCGI(w, controller, reader)278 if streamErr != nil {279 cancel()280 _ = stdout.Close()281 _ = req.Body.Close()282 }283 waitErr := cmd.Wait()284 if waitErr != nil {285 streamErr = errors.Join(streamErr, fmt.Errorf("git http-backend: %w: %s", waitErr, strings.TrimSpace(stderr.String())))286 }287 if streamErr != nil && started {288 return &cgiStreamError{streamErr}289 }290 return streamErr291}292293// Compressed smart-HTTP negotiation needs a decoded CONTENT_LENGTH for CGI.294// Spool this uncommon request form to disk so memory stays independent of size.295func cgiInput(req *http.Request) (io.Reader, string, func(), error) {296 length := req.Header.Get("Content-Length")297 if !strings.EqualFold(req.Header.Get("Content-Encoding"), "gzip") {298 return req.Body, length, func() {}, nil299 }300 zr, err := gzip.NewReader(req.Body)301 if err != nil {302 return nil, "", nil, fmt.Errorf("decode compressed Git request: %w", err)303 }304 defer zr.Close()305 file, err := os.CreateTemp("", "tiny-git-request-*")306 if err != nil {307 return nil, "", nil, err308 }309 cleanup := func() { _ = file.Close(); _ = os.Remove(file.Name()) }310 n, err := io.Copy(file, zr)311 if err == nil {312 _, err = file.Seek(0, io.SeekStart)313 }314 if err != nil {315 cleanup()316 return nil, "", nil, fmt.Errorf("spool compressed Git request: %w", err)317 }318 return file, strconv.FormatInt(n, 10), cleanup, nil319}320321type cgiStreamError struct{ error }322323// Only diagnostic text is bounded; pack streams have no product size limit.324type cgiDiagnostic struct{ bytes.Buffer }325326func (b *cgiDiagnostic) Write(p []byte) (int, error) {327 n := len(p)328 if remaining := 64*1024 - b.Len(); remaining > 0 {329 _, _ = b.Buffer.Write(p[:min(remaining, n)])330 }331 return n, nil332}333334func streamCGI(w http.ResponseWriter, controller *http.ResponseController, reader *bufio.Reader) (bool, error) {335 status := http.StatusOK336 responseHeaders := make(http.Header)337 for {338 line, err := reader.ReadString('\n')339 if err != nil {340 return false, fmt.Errorf("read Git response headers: %w", err)341 }342 line = strings.TrimRight(line, "\r\n")343 if line == "" {344 break345 }346 k, v, ok := strings.Cut(line, ":")347 if !ok {348 return false, errors.New("malformed Git CGI response header")349 }350 if strings.EqualFold(k, "Status") {351 if _, err := fmt.Sscanf(v, "%d", &status); err != nil || status < 200 || status > 599 {352 return false, errors.New("invalid Git CGI response status")353 }354 } else {355 responseHeaders.Add(k, strings.TrimSpace(v))356 }357 }358 // Hold the response until git produces its first body byte. HTTP/1.1359 // proxies without full duplex discard the unread request body once a360 // response starts, which would truncate a pack that is still uploading.361 // git-http-backend writes its headers before reading the request, but362 // writes body bytes only after it has consumed the pack.363 if _, err := reader.Peek(1); err != nil && !errors.Is(err, io.EOF) {364 return false, fmt.Errorf("read Git response body: %w", err)365 }366 for k, values := range responseHeaders {367 for _, value := range values {368 w.Header().Add(k, value)369 }370 }371 w.WriteHeader(status)372 if err := controller.Flush(); err != nil && !errors.Is(err, http.ErrNotSupported) {373 return true, err374 }375 _, err := io.Copy(cgiFlushWriter{w, controller}, reader)376 return true, err377}378379type cgiFlushWriter struct {380 writer io.Writer381 controller *http.ResponseController382}383384func (w cgiFlushWriter) Write(p []byte) (int, error) {385 n, err := w.writer.Write(p)386 if err == nil {387 err = w.controller.Flush()388 if errors.Is(err, http.ErrNotSupported) {389 err = nil390 }391 }392 return n, err393}