├── .github └── workflows │ └── docker.yaml ├── .gitignore ├── config.yaml ├── config └── config.go ├── contrib ├── Dockerfile ├── docker-compose.yml └── legit.service ├── flake.lock ├── flake.nix ├── git ├── diff.go ├── git.go ├── service │ ├── service.go │ └── write_flusher.go └── tree.go ├── go.mod ├── go.sum ├── license ├── main.go ├── readme ├── routes ├── git.go ├── handler.go ├── routes.go ├── template.go └── util.go ├── static ├── legit.png └── style.css ├── templates ├── 404.html ├── 500.html ├── commit.html ├── file.html ├── head.html ├── index.html ├── log.html ├── nav.html ├── refs.html ├── repo-header.html ├── repo.html └── tree.html ├── unveil.go └── unveil_stub.go /.github/workflows/docker.yaml: -------------------------------------------------------------------------------- 1 | name: build and push docker image 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | tags: ["*"] 7 | 8 | env: 9 | REGISTRY: ghcr.io 10 | IMAGE_NAME: ${{ github.repository }} 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | permissions: 16 | contents: read 17 | packages: write 18 | attestations: write 19 | id-token: write 20 | 21 | steps: 22 | - name: Check out code 23 | uses: actions/checkout@v4 24 | 25 | - name: Copy Dockerfile to . 26 | run: cp ./contrib/Dockerfile ./Dockerfile 27 | 28 | - name: Set up Docker Buildx 29 | uses: docker/setup-buildx-action@v3 30 | 31 | - name: Login to GitHub Container Registry 32 | uses: docker/login-action@v3 33 | with: 34 | registry: ghcr.io 35 | username: ${{ github.repository_owner }} 36 | password: ${{ secrets.GITHUB_TOKEN }} 37 | 38 | - name: Docker meta 39 | id: meta 40 | uses: docker/metadata-action@v5 41 | with: 42 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 43 | 44 | - name: Build and push Docker image 45 | uses: docker/build-push-action@v6 46 | with: 47 | context: . 48 | tags: ${{ steps.meta.outputs.tags }} 49 | labels: ${{ steps.meta.outputs.labels }} 50 | push: true 51 | build-args: | 52 | BUILDKIT_INLINE_CACHE=1 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | legit 2 | result 3 | -------------------------------------------------------------------------------- /config.yaml: -------------------------------------------------------------------------------- 1 | repo: 2 | scanPath: /var/www/git 3 | readme: 4 | - readme 5 | - README 6 | - readme.md 7 | - README.md 8 | mainBranch: 9 | - master 10 | - main 11 | dirs: 12 | templates: ./templates 13 | static: ./static 14 | meta: 15 | title: icy does git 16 | description: come get your free software 17 | server: 18 | name: git.icyphox.sh 19 | host: 0.0.0.0 20 | port: 5555 21 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | 8 | "gopkg.in/yaml.v3" 9 | ) 10 | 11 | type Config struct { 12 | Repo struct { 13 | ScanPath string `yaml:"scanPath"` 14 | Readme []string `yaml:"readme"` 15 | MainBranch []string `yaml:"mainBranch"` 16 | Ignore []string `yaml:"ignore,omitempty"` 17 | Unlisted []string `yaml:"unlisted,omitempty"` 18 | } `yaml:"repo"` 19 | Dirs struct { 20 | Templates string `yaml:"templates"` 21 | Static string `yaml:"static"` 22 | } `yaml:"dirs"` 23 | Meta struct { 24 | Title string `yaml:"title"` 25 | Description string `yaml:"description"` 26 | SyntaxHighlight string `yaml:"syntaxHighlight"` 27 | } `yaml:"meta"` 28 | Server struct { 29 | Name string `yaml:"name,omitempty"` 30 | Host string `yaml:"host"` 31 | Port int `yaml:"port"` 32 | } `yaml:"server"` 33 | } 34 | 35 | func Read(f string) (*Config, error) { 36 | b, err := os.ReadFile(f) 37 | if err != nil { 38 | return nil, fmt.Errorf("reading config: %w", err) 39 | } 40 | 41 | c := Config{} 42 | if err := yaml.Unmarshal(b, &c); err != nil { 43 | return nil, fmt.Errorf("parsing config: %w", err) 44 | } 45 | 46 | if c.Repo.ScanPath, err = filepath.Abs(c.Repo.ScanPath); err != nil { 47 | return nil, err 48 | } 49 | if c.Dirs.Templates, err = filepath.Abs(c.Dirs.Templates); err != nil { 50 | return nil, err 51 | } 52 | if c.Dirs.Static, err = filepath.Abs(c.Dirs.Static); err != nil { 53 | return nil, err 54 | } 55 | 56 | return &c, nil 57 | } 58 | -------------------------------------------------------------------------------- /contrib/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.22-alpine AS builder 2 | 3 | WORKDIR /app 4 | 5 | COPY . . 6 | RUN go mod download 7 | RUN go mod verify 8 | 9 | RUN go build -o legit 10 | 11 | FROM scratch AS build-release-stage 12 | 13 | WORKDIR /app 14 | 15 | COPY static ./static 16 | COPY templates ./templates 17 | COPY config.yaml ./ 18 | COPY --from=builder /app/legit ./ 19 | 20 | EXPOSE 5555 21 | 22 | CMD ["./legit"] 23 | -------------------------------------------------------------------------------- /contrib/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | legit: 3 | container_name: legit 4 | build: 5 | context: ../ 6 | dockerfile: contrib/Dockerfile 7 | restart: unless-stopped 8 | ports: 9 | - "5555:5555" 10 | volumes: 11 | - /var/www/git:/var/www/git 12 | - ../config.yaml:/app/config.yaml 13 | - ../static:/app/static 14 | - ../templates:/app/templates 15 | -------------------------------------------------------------------------------- /contrib/legit.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=legit Server 3 | After=network-online.target 4 | Requires=network-online.target 5 | 6 | [Service] 7 | User=git 8 | Group=git 9 | ExecStart=/usr/bin/legit -config /etc/legit/config.yaml 10 | ProtectSystem=strict 11 | ProtectHome=strict 12 | NoNewPrivileges=true 13 | PrivateTmp=true 14 | PrivateDevices=true 15 | 16 | [Install] 17 | WantedBy=multi-user.target 18 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "nixpkgs": { 4 | "locked": { 5 | "lastModified": 1718558927, 6 | "narHash": "sha256-PRqvkPqX5luuZ0WcUbz2zATGp4IzybDU0K33MxO9Sd0=", 7 | "owner": "nixos", 8 | "repo": "nixpkgs", 9 | "rev": "f82fe275d98c521c051af4892cd8b3406cee67a3", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "nixos", 14 | "repo": "nixpkgs", 15 | "type": "github" 16 | } 17 | }, 18 | "root": { 19 | "inputs": { 20 | "nixpkgs": "nixpkgs" 21 | } 22 | } 23 | }, 24 | "root": "root", 25 | "version": 7 26 | } 27 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "web frontend for git"; 3 | 4 | inputs.nixpkgs.url = "github:nixos/nixpkgs"; 5 | 6 | outputs = 7 | { self 8 | , nixpkgs 9 | , 10 | }: 11 | let 12 | supportedSystems = [ "x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin" ]; 13 | forAllSystems = nixpkgs.lib.genAttrs supportedSystems; 14 | nixpkgsFor = forAllSystems (system: import nixpkgs { inherit system; }); 15 | in 16 | { 17 | packages = forAllSystems (system: 18 | let 19 | pkgs = nixpkgsFor.${system}; 20 | legit = self.packages.${system}.legit; 21 | files = pkgs.lib.fileset.toSource { 22 | root = ./.; 23 | fileset = pkgs.lib.fileset.unions [ 24 | ./config.yaml 25 | ./static 26 | ./templates 27 | ]; 28 | }; 29 | in 30 | { 31 | legit = pkgs.buildGoModule { 32 | name = "legit"; 33 | rev = "master"; 34 | src = ./.; 35 | 36 | vendorHash = "sha256-ynv0pBdVPIhTz7RvCwVWr0vUWwfw+PEjFXs9PdQMqm8="; 37 | }; 38 | docker = pkgs.dockerTools.buildLayeredImage { 39 | name = "sini:5000/legit"; 40 | tag = "latest"; 41 | contents = [ files legit pkgs.git ]; 42 | config = { 43 | Entrypoint = [ "${legit}/bin/legit" ]; 44 | ExposedPorts = { "5555/tcp" = { }; }; 45 | }; 46 | }; 47 | }); 48 | 49 | defaultPackage = forAllSystems (system: self.packages.${system}.legit); 50 | devShells = forAllSystems (system: 51 | let 52 | pkgs = nixpkgsFor.${system}; 53 | in 54 | { 55 | default = pkgs.mkShell { 56 | nativeBuildInputs = with pkgs; [ 57 | go 58 | ]; 59 | }; 60 | }); 61 | }; 62 | } 63 | -------------------------------------------------------------------------------- /git/diff.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "strings" 7 | 8 | "github.com/bluekeyes/go-gitdiff/gitdiff" 9 | "github.com/go-git/go-git/v5/plumbing/object" 10 | ) 11 | 12 | type TextFragment struct { 13 | Header string 14 | Lines []gitdiff.Line 15 | } 16 | 17 | type Diff struct { 18 | Name struct { 19 | Old string 20 | New string 21 | } 22 | TextFragments []TextFragment 23 | IsBinary bool 24 | IsNew bool 25 | IsDelete bool 26 | } 27 | 28 | // A nicer git diff representation. 29 | type NiceDiff struct { 30 | Commit struct { 31 | Message string 32 | Author object.Signature 33 | This string 34 | Parent string 35 | } 36 | Stat struct { 37 | FilesChanged int 38 | Insertions int 39 | Deletions int 40 | } 41 | Diff []Diff 42 | } 43 | 44 | func (g *GitRepo) Diff() (*NiceDiff, error) { 45 | c, err := g.r.CommitObject(g.h) 46 | if err != nil { 47 | return nil, fmt.Errorf("commit object: %w", err) 48 | } 49 | 50 | patch := &object.Patch{} 51 | commitTree, err := c.Tree() 52 | parent := &object.Commit{} 53 | if err == nil { 54 | parentTree := &object.Tree{} 55 | if c.NumParents() != 0 { 56 | parent, err = c.Parents().Next() 57 | if err == nil { 58 | parentTree, err = parent.Tree() 59 | if err == nil { 60 | patch, err = parentTree.Patch(commitTree) 61 | if err != nil { 62 | return nil, fmt.Errorf("patch: %w", err) 63 | } 64 | } 65 | } 66 | } else { 67 | patch, err = parentTree.Patch(commitTree) 68 | if err != nil { 69 | return nil, fmt.Errorf("patch: %w", err) 70 | } 71 | } 72 | } 73 | 74 | diffs, _, err := gitdiff.Parse(strings.NewReader(patch.String())) 75 | if err != nil { 76 | log.Println(err) 77 | } 78 | 79 | nd := NiceDiff{} 80 | nd.Commit.This = c.Hash.String() 81 | 82 | if parent.Hash.IsZero() { 83 | nd.Commit.Parent = "" 84 | } else { 85 | nd.Commit.Parent = parent.Hash.String() 86 | } 87 | nd.Commit.Author = c.Author 88 | nd.Commit.Message = c.Message 89 | 90 | for _, d := range diffs { 91 | ndiff := Diff{} 92 | ndiff.Name.New = d.NewName 93 | ndiff.Name.Old = d.OldName 94 | ndiff.IsBinary = d.IsBinary 95 | ndiff.IsNew = d.IsNew 96 | ndiff.IsDelete = d.IsDelete 97 | 98 | for _, tf := range d.TextFragments { 99 | ndiff.TextFragments = append(ndiff.TextFragments, TextFragment{ 100 | Header: tf.Header(), 101 | Lines: tf.Lines, 102 | }) 103 | for _, l := range tf.Lines { 104 | switch l.Op { 105 | case gitdiff.OpAdd: 106 | nd.Stat.Insertions += 1 107 | case gitdiff.OpDelete: 108 | nd.Stat.Deletions += 1 109 | } 110 | } 111 | } 112 | 113 | nd.Diff = append(nd.Diff, ndiff) 114 | } 115 | 116 | nd.Stat.FilesChanged = len(diffs) 117 | 118 | return &nd, nil 119 | } 120 | -------------------------------------------------------------------------------- /git/git.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "archive/tar" 5 | "fmt" 6 | "io" 7 | "io/fs" 8 | "path" 9 | "sort" 10 | "time" 11 | 12 | "github.com/go-git/go-git/v5" 13 | "github.com/go-git/go-git/v5/plumbing" 14 | "github.com/go-git/go-git/v5/plumbing/object" 15 | ) 16 | 17 | type GitRepo struct { 18 | r *git.Repository 19 | h plumbing.Hash 20 | } 21 | 22 | type TagList struct { 23 | refs []*TagReference 24 | r *git.Repository 25 | } 26 | 27 | // TagReference is used to list both tag and non-annotated tags. 28 | // Non-annotated tags should only contains a reference. 29 | // Annotated tags should contain its reference and its tag information. 30 | type TagReference struct { 31 | ref *plumbing.Reference 32 | tag *object.Tag 33 | } 34 | 35 | // infoWrapper wraps the property of a TreeEntry so it can export fs.FileInfo 36 | // to tar WriteHeader 37 | type infoWrapper struct { 38 | name string 39 | size int64 40 | mode fs.FileMode 41 | modTime time.Time 42 | isDir bool 43 | } 44 | 45 | func (self *TagList) Len() int { 46 | return len(self.refs) 47 | } 48 | 49 | func (self *TagList) Swap(i, j int) { 50 | self.refs[i], self.refs[j] = self.refs[j], self.refs[i] 51 | } 52 | 53 | // sorting tags in reverse chronological order 54 | func (self *TagList) Less(i, j int) bool { 55 | var dateI time.Time 56 | var dateJ time.Time 57 | 58 | if self.refs[i].tag != nil { 59 | dateI = self.refs[i].tag.Tagger.When 60 | } else { 61 | c, err := self.r.CommitObject(self.refs[i].ref.Hash()) 62 | if err != nil { 63 | dateI = time.Now() 64 | } else { 65 | dateI = c.Committer.When 66 | } 67 | } 68 | 69 | if self.refs[j].tag != nil { 70 | dateJ = self.refs[j].tag.Tagger.When 71 | } else { 72 | c, err := self.r.CommitObject(self.refs[j].ref.Hash()) 73 | if err != nil { 74 | dateJ = time.Now() 75 | } else { 76 | dateJ = c.Committer.When 77 | } 78 | } 79 | 80 | return dateI.After(dateJ) 81 | } 82 | 83 | func Open(path string, ref string) (*GitRepo, error) { 84 | var err error 85 | g := GitRepo{} 86 | g.r, err = git.PlainOpen(path) 87 | if err != nil { 88 | return nil, fmt.Errorf("opening %s: %w", path, err) 89 | } 90 | 91 | if ref == "" { 92 | head, err := g.r.Head() 93 | if err != nil { 94 | return nil, fmt.Errorf("getting head of %s: %w", path, err) 95 | } 96 | g.h = head.Hash() 97 | } else { 98 | hash, err := g.r.ResolveRevision(plumbing.Revision(ref)) 99 | if err != nil { 100 | return nil, fmt.Errorf("resolving rev %s for %s: %w", ref, path, err) 101 | } 102 | g.h = *hash 103 | } 104 | return &g, nil 105 | } 106 | 107 | func (g *GitRepo) Commits() ([]*object.Commit, error) { 108 | ci, err := g.r.Log(&git.LogOptions{From: g.h}) 109 | if err != nil { 110 | return nil, fmt.Errorf("commits from ref: %w", err) 111 | } 112 | 113 | commits := []*object.Commit{} 114 | ci.ForEach(func(c *object.Commit) error { 115 | commits = append(commits, c) 116 | return nil 117 | }) 118 | 119 | return commits, nil 120 | } 121 | 122 | func (g *GitRepo) LastCommit() (*object.Commit, error) { 123 | c, err := g.r.CommitObject(g.h) 124 | if err != nil { 125 | return nil, fmt.Errorf("last commit: %w", err) 126 | } 127 | return c, nil 128 | } 129 | 130 | func (g *GitRepo) FileContent(path string) (string, error) { 131 | c, err := g.r.CommitObject(g.h) 132 | if err != nil { 133 | return "", fmt.Errorf("commit object: %w", err) 134 | } 135 | 136 | tree, err := c.Tree() 137 | if err != nil { 138 | return "", fmt.Errorf("file tree: %w", err) 139 | } 140 | 141 | file, err := tree.File(path) 142 | if err != nil { 143 | return "", err 144 | } 145 | 146 | isbin, _ := file.IsBinary() 147 | 148 | if !isbin { 149 | return file.Contents() 150 | } else { 151 | return "Not displaying binary file", nil 152 | } 153 | } 154 | 155 | func (g *GitRepo) Tags() ([]*TagReference, error) { 156 | iter, err := g.r.Tags() 157 | if err != nil { 158 | return nil, fmt.Errorf("tag objects: %w", err) 159 | } 160 | 161 | tags := make([]*TagReference, 0) 162 | 163 | if err := iter.ForEach(func(ref *plumbing.Reference) error { 164 | obj, err := g.r.TagObject(ref.Hash()) 165 | switch err { 166 | case nil: 167 | tags = append(tags, &TagReference{ 168 | ref: ref, 169 | tag: obj, 170 | }) 171 | case plumbing.ErrObjectNotFound: 172 | tags = append(tags, &TagReference{ 173 | ref: ref, 174 | }) 175 | default: 176 | return err 177 | } 178 | return nil 179 | }); err != nil { 180 | return nil, err 181 | } 182 | 183 | tagList := &TagList{r: g.r, refs: tags} 184 | sort.Sort(tagList) 185 | return tags, nil 186 | } 187 | 188 | func (g *GitRepo) Branches() ([]*plumbing.Reference, error) { 189 | bi, err := g.r.Branches() 190 | if err != nil { 191 | return nil, fmt.Errorf("branchs: %w", err) 192 | } 193 | 194 | branches := []*plumbing.Reference{} 195 | 196 | _ = bi.ForEach(func(ref *plumbing.Reference) error { 197 | branches = append(branches, ref) 198 | return nil 199 | }) 200 | 201 | return branches, nil 202 | } 203 | 204 | func (g *GitRepo) FindMainBranch(branches []string) (string, error) { 205 | for _, b := range branches { 206 | _, err := g.r.ResolveRevision(plumbing.Revision(b)) 207 | if err == nil { 208 | return b, nil 209 | } 210 | } 211 | return "", fmt.Errorf("unable to find main branch") 212 | } 213 | 214 | // WriteTar writes itself from a tree into a binary tar file format. 215 | // prefix is root folder to be appended. 216 | func (g *GitRepo) WriteTar(w io.Writer, prefix string) error { 217 | tw := tar.NewWriter(w) 218 | defer tw.Close() 219 | 220 | c, err := g.r.CommitObject(g.h) 221 | if err != nil { 222 | return fmt.Errorf("commit object: %w", err) 223 | } 224 | 225 | tree, err := c.Tree() 226 | if err != nil { 227 | return err 228 | } 229 | 230 | walker := object.NewTreeWalker(tree, true, nil) 231 | defer walker.Close() 232 | 233 | name, entry, err := walker.Next() 234 | for ; err == nil; name, entry, err = walker.Next() { 235 | info, err := newInfoWrapper(name, prefix, &entry, tree) 236 | if err != nil { 237 | return err 238 | } 239 | 240 | header, err := tar.FileInfoHeader(info, "") 241 | if err != nil { 242 | return err 243 | } 244 | 245 | err = tw.WriteHeader(header) 246 | if err != nil { 247 | return err 248 | } 249 | 250 | if !info.IsDir() { 251 | file, err := tree.File(name) 252 | if err != nil { 253 | return err 254 | } 255 | 256 | reader, err := file.Blob.Reader() 257 | if err != nil { 258 | return err 259 | } 260 | 261 | _, err = io.Copy(tw, reader) 262 | if err != nil { 263 | reader.Close() 264 | return err 265 | } 266 | reader.Close() 267 | } 268 | } 269 | 270 | return nil 271 | } 272 | 273 | func newInfoWrapper( 274 | name string, 275 | prefix string, 276 | entry *object.TreeEntry, 277 | tree *object.Tree, 278 | ) (*infoWrapper, error) { 279 | var ( 280 | size int64 281 | mode fs.FileMode 282 | isDir bool 283 | ) 284 | 285 | if entry.Mode.IsFile() { 286 | file, err := tree.TreeEntryFile(entry) 287 | if err != nil { 288 | return nil, err 289 | } 290 | mode = fs.FileMode(file.Mode) 291 | 292 | size, err = tree.Size(name) 293 | if err != nil { 294 | return nil, err 295 | } 296 | } else { 297 | isDir = true 298 | mode = fs.ModeDir | fs.ModePerm 299 | } 300 | 301 | fullname := path.Join(prefix, name) 302 | return &infoWrapper{ 303 | name: fullname, 304 | size: size, 305 | mode: mode, 306 | modTime: time.Unix(0, 0), 307 | isDir: isDir, 308 | }, nil 309 | } 310 | 311 | func (i *infoWrapper) Name() string { 312 | return i.name 313 | } 314 | 315 | func (i *infoWrapper) Size() int64 { 316 | return i.size 317 | } 318 | 319 | func (i *infoWrapper) Mode() fs.FileMode { 320 | return i.mode 321 | } 322 | 323 | func (i *infoWrapper) ModTime() time.Time { 324 | return i.modTime 325 | } 326 | 327 | func (i *infoWrapper) IsDir() bool { 328 | return i.isDir 329 | } 330 | 331 | func (i *infoWrapper) Sys() any { 332 | return nil 333 | } 334 | 335 | func (t *TagReference) Name() string { 336 | return t.ref.Name().Short() 337 | } 338 | 339 | func (t *TagReference) Message() string { 340 | if t.tag != nil { 341 | return t.tag.Message 342 | } 343 | return "" 344 | } 345 | -------------------------------------------------------------------------------- /git/service/service.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "log" 8 | "net/http" 9 | "os/exec" 10 | "strings" 11 | "syscall" 12 | ) 13 | 14 | // Mostly from charmbracelet/soft-serve and sosedoff/gitkit. 15 | 16 | type ServiceCommand struct { 17 | Dir string 18 | Stdin io.Reader 19 | Stdout http.ResponseWriter 20 | } 21 | 22 | func (c *ServiceCommand) InfoRefs() error { 23 | cmd := exec.Command("git", []string{ 24 | "upload-pack", 25 | "--stateless-rpc", 26 | "--advertise-refs", 27 | ".", 28 | }...) 29 | 30 | cmd.Dir = c.Dir 31 | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} 32 | stdoutPipe, _ := cmd.StdoutPipe() 33 | cmd.Stderr = cmd.Stdout 34 | 35 | if err := cmd.Start(); err != nil { 36 | log.Printf("git: failed to start git-upload-pack (info/refs): %s", err) 37 | return err 38 | } 39 | 40 | if err := packLine(c.Stdout, "# service=git-upload-pack\n"); err != nil { 41 | log.Printf("git: failed to write pack line: %s", err) 42 | return err 43 | } 44 | 45 | if err := packFlush(c.Stdout); err != nil { 46 | log.Printf("git: failed to flush pack: %s", err) 47 | return err 48 | } 49 | 50 | buf := bytes.Buffer{} 51 | if _, err := io.Copy(&buf, stdoutPipe); err != nil { 52 | log.Printf("git: failed to copy stdout to tmp buffer: %s", err) 53 | return err 54 | } 55 | 56 | if err := cmd.Wait(); err != nil { 57 | out := strings.Builder{} 58 | _, _ = io.Copy(&out, &buf) 59 | log.Printf("git: failed to run git-upload-pack; err: %s; output: %s", err, out.String()) 60 | return err 61 | } 62 | 63 | if _, err := io.Copy(c.Stdout, &buf); err != nil { 64 | log.Printf("git: failed to copy stdout: %s", err) 65 | } 66 | 67 | return nil 68 | } 69 | 70 | func (c *ServiceCommand) UploadPack() error { 71 | cmd := exec.Command("git", []string{ 72 | "-c", "uploadpack.allowFilter=true", 73 | "upload-pack", 74 | "--stateless-rpc", 75 | ".", 76 | }...) 77 | cmd.Dir = c.Dir 78 | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} 79 | 80 | stdoutPipe, _ := cmd.StdoutPipe() 81 | cmd.Stderr = cmd.Stdout 82 | defer stdoutPipe.Close() 83 | 84 | stdinPipe, err := cmd.StdinPipe() 85 | if err != nil { 86 | return err 87 | } 88 | defer stdinPipe.Close() 89 | 90 | if err := cmd.Start(); err != nil { 91 | log.Printf("git: failed to start git-upload-pack: %s", err) 92 | return err 93 | } 94 | 95 | if _, err := io.Copy(stdinPipe, c.Stdin); err != nil { 96 | log.Printf("git: failed to copy stdin: %s", err) 97 | return err 98 | } 99 | stdinPipe.Close() 100 | 101 | if _, err := io.Copy(newWriteFlusher(c.Stdout), stdoutPipe); err != nil { 102 | log.Printf("git: failed to copy stdout: %s", err) 103 | return err 104 | } 105 | if err := cmd.Wait(); err != nil { 106 | log.Printf("git: failed to wait for git-upload-pack: %s", err) 107 | return err 108 | } 109 | 110 | return nil 111 | } 112 | 113 | func packLine(w io.Writer, s string) error { 114 | _, err := fmt.Fprintf(w, "%04x%s", len(s)+4, s) 115 | return err 116 | } 117 | 118 | func packFlush(w io.Writer) error { 119 | _, err := fmt.Fprint(w, "0000") 120 | return err 121 | } 122 | -------------------------------------------------------------------------------- /git/service/write_flusher.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | ) 7 | 8 | func newWriteFlusher(w http.ResponseWriter) io.Writer { 9 | return writeFlusher{w.(interface { 10 | io.Writer 11 | http.Flusher 12 | })} 13 | } 14 | 15 | type writeFlusher struct { 16 | wf interface { 17 | io.Writer 18 | http.Flusher 19 | } 20 | } 21 | 22 | func (w writeFlusher) Write(p []byte) (int, error) { 23 | defer w.wf.Flush() 24 | return w.wf.Write(p) 25 | } 26 | -------------------------------------------------------------------------------- /git/tree.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/go-git/go-git/v5/plumbing/object" 7 | ) 8 | 9 | func (g *GitRepo) FileTree(path string) ([]NiceTree, error) { 10 | c, err := g.r.CommitObject(g.h) 11 | if err != nil { 12 | return nil, fmt.Errorf("commit object: %w", err) 13 | } 14 | 15 | files := []NiceTree{} 16 | tree, err := c.Tree() 17 | if err != nil { 18 | return nil, fmt.Errorf("file tree: %w", err) 19 | } 20 | 21 | if path == "" { 22 | files = makeNiceTree(tree) 23 | } else { 24 | o, err := tree.FindEntry(path) 25 | if err != nil { 26 | return nil, err 27 | } 28 | 29 | if !o.Mode.IsFile() { 30 | subtree, err := tree.Tree(path) 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | files = makeNiceTree(subtree) 36 | } 37 | } 38 | 39 | return files, nil 40 | } 41 | 42 | // A nicer git tree representation. 43 | type NiceTree struct { 44 | Name string 45 | Mode string 46 | Size int64 47 | IsFile bool 48 | IsSubtree bool 49 | } 50 | 51 | func makeNiceTree(t *object.Tree) []NiceTree { 52 | nts := []NiceTree{} 53 | 54 | for _, e := range t.Entries { 55 | mode, _ := e.Mode.ToOSFileMode() 56 | sz, _ := t.Size(e.Name) 57 | nts = append(nts, NiceTree{ 58 | Name: e.Name, 59 | Mode: mode.String(), 60 | IsFile: e.Mode.IsFile(), 61 | Size: sz, 62 | }) 63 | } 64 | 65 | return nts 66 | } 67 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module git.icyphox.sh/legit 2 | 3 | go 1.22.0 4 | 5 | require ( 6 | github.com/alecthomas/chroma/v2 v2.14.0 7 | github.com/bluekeyes/go-gitdiff v0.8.0 8 | github.com/cyphar/filepath-securejoin v0.4.1 9 | github.com/dustin/go-humanize v1.0.1 10 | github.com/go-git/go-git/v5 v5.13.2 11 | github.com/microcosm-cc/bluemonday v1.0.27 12 | github.com/russross/blackfriday/v2 v2.1.0 13 | golang.org/x/sys v0.30.0 14 | gopkg.in/yaml.v3 v3.0.1 15 | ) 16 | 17 | require ( 18 | github.com/Microsoft/go-winio v0.6.2 // indirect 19 | github.com/ProtonMail/go-crypto v1.1.5 // indirect 20 | github.com/acomagu/bufpipe v1.0.4 // indirect 21 | github.com/aymerick/douceur v0.2.0 // indirect 22 | github.com/cloudflare/circl v1.6.0 // indirect 23 | github.com/dlclark/regexp2 v1.11.4 // indirect 24 | github.com/emirpasic/gods v1.18.1 // indirect 25 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 26 | github.com/go-git/go-billy/v5 v5.6.2 // indirect 27 | github.com/gorilla/css v1.0.1 // indirect 28 | github.com/imdario/mergo v0.3.16 // indirect 29 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect 30 | github.com/kevinburke/ssh_config v1.2.0 // indirect 31 | github.com/pjbgf/sha1cd v0.3.2 // indirect 32 | github.com/sergi/go-diff v1.3.1 // indirect 33 | github.com/skeema/knownhosts v1.3.1 // indirect 34 | github.com/xanzy/ssh-agent v0.3.3 // indirect 35 | golang.org/x/crypto v0.33.0 // indirect 36 | golang.org/x/net v0.35.0 // indirect 37 | gopkg.in/warnings.v0 v0.1.2 // indirect 38 | ) 39 | 40 | replace github.com/sergi/go-diff => github.com/sergi/go-diff v1.1.0 41 | 42 | replace github.com/go-git/go-git/v5 => github.com/go-git/go-git/v5 v5.6.1 43 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= 2 | github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= 3 | github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= 4 | github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8/go.mod h1:I0gYDMZ6Z5GRU7l58bNFSkPTFN6Yl12dsUlAZ8xy98g= 5 | github.com/ProtonMail/go-crypto v1.1.5 h1:eoAQfK2dwL+tFSFpr7TbOaPNUbPiJj4fLYwwGE1FQO4= 6 | github.com/ProtonMail/go-crypto v1.1.5/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= 7 | github.com/acomagu/bufpipe v1.0.4 h1:e3H4WUzM3npvo5uv95QuJM3cQspFNtFBzvJ2oNjKIDQ= 8 | github.com/acomagu/bufpipe v1.0.4/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4= 9 | github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= 10 | github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 11 | github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= 12 | github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= 13 | github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= 14 | github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= 15 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= 16 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= 17 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= 18 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= 19 | github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= 20 | github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 21 | github.com/bluekeyes/go-gitdiff v0.8.0 h1:Nn1wfw3/XeKoc3lWk+2bEXGUHIx36kj80FM1gVcBk+o= 22 | github.com/bluekeyes/go-gitdiff v0.8.0/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE= 23 | github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= 24 | github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I= 25 | github.com/cloudflare/circl v1.6.0 h1:cr5JKic4HI+LkINy2lg3W2jF8sHCVTBncJr5gIIq7qk= 26 | github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= 27 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 28 | github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= 29 | github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= 30 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 31 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 32 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 33 | github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= 34 | github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 35 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 36 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 37 | github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= 38 | github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= 39 | github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY= 40 | github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4= 41 | github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E= 42 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= 43 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= 44 | github.com/go-git/go-billy/v5 v5.3.1/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0= 45 | github.com/go-git/go-billy/v5 v5.4.1/go.mod h1:vjbugF6Fz7JIflbVpl1hJsGjSHNltrSw45YK/ukIvQg= 46 | github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= 47 | github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= 48 | github.com/go-git/go-git-fixtures/v4 v4.3.1 h1:y5z6dd3qi8Hl+stezc8p3JxDkoTRqMAlKnXHuzrfjTQ= 49 | github.com/go-git/go-git-fixtures/v4 v4.3.1/go.mod h1:8LHG1a3SRW71ettAD/jW13h8c6AqjVSeL11RAdgaqpo= 50 | github.com/go-git/go-git/v5 v5.6.1 h1:q4ZRqQl4pR/ZJHc1L5CFjGA1a10u76aV1iC+nh+bHsk= 51 | github.com/go-git/go-git/v5 v5.6.1/go.mod h1:mvyoL6Unz0PiTQrGQfSfiLFhBH1c1e84ylC2MDs4ee8= 52 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 53 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 54 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 55 | github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= 56 | github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= 57 | github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 58 | github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 59 | github.com/imdario/mergo v0.3.13/go.mod h1:4lJ1jqUDcsbIECGy0RUJAXNIhg+6ocWgb1ALK2O4oXg= 60 | github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= 61 | github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= 62 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= 63 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= 64 | github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= 65 | github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= 66 | github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= 67 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 68 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 69 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 70 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 71 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 72 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 73 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 74 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 75 | github.com/matryer/is v1.2.0 h1:92UTHpy8CDwaJ08GqLDzhhuixiBUUD1p3AU6PHddz4A= 76 | github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA= 77 | github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= 78 | github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= 79 | github.com/mmcloughlin/avo v0.5.0/go.mod h1:ChHFdoV7ql95Wi7vuq2YT1bwCJqiWdZrQ1im3VujLYM= 80 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= 81 | github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= 82 | github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= 83 | github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= 84 | github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= 85 | github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= 86 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 87 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 88 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 89 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 90 | github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= 91 | github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= 92 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 93 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 94 | github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= 95 | github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= 96 | github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= 97 | github.com/skeema/knownhosts v1.1.0/go.mod h1:sKFq3RD6/TKZkSWn8boUbDC7Qkgcv+8XXijpFO6roag= 98 | github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= 99 | github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= 100 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 101 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 102 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 103 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 104 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 105 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 106 | github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= 107 | github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= 108 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 109 | golang.org/x/arch v0.1.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= 110 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 111 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 112 | golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 113 | golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 114 | golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 115 | golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= 116 | golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= 117 | golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= 118 | golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= 119 | golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= 120 | golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= 121 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 122 | golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI= 123 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 124 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 125 | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 126 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 127 | golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= 128 | golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= 129 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 130 | golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 131 | golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= 132 | golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= 133 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 134 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 135 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 136 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 137 | golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 138 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 139 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 140 | golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 141 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 142 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 143 | golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 144 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 145 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 146 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 147 | golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 148 | golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 149 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 150 | golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 151 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 152 | golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= 153 | golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 154 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 155 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 156 | golang.org/x/term v0.0.0-20220722155259-a9ba230a4035/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 157 | golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 158 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 159 | golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= 160 | golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= 161 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 162 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 163 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 164 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 165 | golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 166 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 167 | golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= 168 | golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= 169 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 170 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 171 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 172 | golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA= 173 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 174 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 175 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 176 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 177 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 178 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 179 | gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= 180 | gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= 181 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 182 | gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 183 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 184 | gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 185 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 186 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 187 | rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= 188 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Anirudh Oppiliappan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a 6 | copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included 14 | in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 17 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | 9 | "git.icyphox.sh/legit/config" 10 | "git.icyphox.sh/legit/routes" 11 | ) 12 | 13 | func main() { 14 | var cfg string 15 | flag.StringVar(&cfg, "config", "./config.yaml", "path to config file") 16 | flag.Parse() 17 | 18 | c, err := config.Read(cfg) 19 | if err != nil { 20 | log.Fatal(err) 21 | } 22 | 23 | if err := UnveilPaths([]string{ 24 | c.Dirs.Static, 25 | c.Repo.ScanPath, 26 | c.Dirs.Templates, 27 | }, 28 | "r"); err != nil { 29 | log.Fatalf("unveil: %s", err) 30 | } 31 | 32 | mux := routes.Handlers(c) 33 | addr := fmt.Sprintf("%s:%d", c.Server.Host, c.Server.Port) 34 | log.Println("starting server on", addr) 35 | log.Fatal(http.ListenAndServe(addr, mux)) 36 | } 37 | -------------------------------------------------------------------------------- /readme: -------------------------------------------------------------------------------- 1 | legit 2 | ----- 3 | 4 | A git web frontend written in Go. 5 | 6 | Pronounced however you like; I prefer channeling my inner beret-wearing 7 | Frenchman, and saying "Oui, il est le git!" 8 | 9 | But yeah it's pretty legit, no cap on god fr fr. 10 | 11 | 12 | FEATURES 13 | 14 | • Fully customizable templates and stylesheets. 15 | • Cloning over http(s). 16 | • Less archaic HTML. 17 | • Not CGI. 18 | 19 | 20 | INSTALLING 21 | 22 | Clone it, 'go build' it. 23 | 24 | 25 | CONFIG 26 | 27 | Uses yaml for configuration. Looks for a 'config.yaml' in the current 28 | directory by default; pass the '--config' flag to point it elsewhere. 29 | 30 | Example config.yaml: 31 | 32 | repo: 33 | scanPath: /var/www/git 34 | readme: 35 | - readme 36 | - README 37 | - readme.md 38 | - README.md 39 | mainBranch: 40 | - master 41 | - main 42 | ignore: 43 | - foo 44 | - bar 45 | dirs: 46 | templates: ./templates 47 | static: ./static 48 | meta: 49 | title: git good 50 | description: i think it's a skill issue 51 | syntaxHighlight: monokailight 52 | server: 53 | name: git.icyphox.sh 54 | host: 127.0.0.1 55 | port: 5555 56 | 57 | These options are fairly self-explanatory, but of note are: 58 | 59 | • repo.scanPath: where all your git repos live (or die). legit doesn't 60 | traverse subdirs yet. 61 | • dirs: use this to override the default templates and static assets. 62 | • repo.readme: readme files to look for. 63 | • repo.mainBranch: main branch names to look for. 64 | • repo.ignore: repos to ignore, relative to scanPath. 65 | • repo.unlisted: repos to hide, relative to scanPath. 66 | • server.name: used for go-import meta tags and clone URLs. 67 | • meta.syntaxHighlight: this is used to select the syntax theme to render. If left 68 | blank or removed, the native theme will be used. If an invalid theme is set in this field, 69 | it will default to "monokailight". For more information 70 | about themes, please refer to chroma's gallery [1]. 71 | 72 | 73 | NOTES 74 | 75 | • Run legit behind a TLS terminating proxy like relayd(8) or nginx. 76 | • Cloning only works in bare repos -- this is a limitation inherent to git. You 77 | can still view non-bare repos just fine in legit. 78 | • Pushing over https, while supported, is disabled because auth is a 79 | pain. Use ssh. 80 | • Paths are unveil(2)'d on OpenBSD. 81 | • Docker images are available ghcr.io/icyphox/legit:{master,latest,vX.Y.Z}. [2] 82 | 83 | LICENSE 84 | 85 | legit is licensed under MIT. 86 | 87 | [1]: https://swapoff.org/chroma/playground/ 88 | [2]: https://github.com/icyphox/legit/pkgs/container/legit 89 | -------------------------------------------------------------------------------- /routes/git.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "compress/gzip" 5 | "io" 6 | "log" 7 | "net/http" 8 | "path/filepath" 9 | 10 | "git.icyphox.sh/legit/git/service" 11 | securejoin "github.com/cyphar/filepath-securejoin" 12 | ) 13 | 14 | func (d *deps) InfoRefs(w http.ResponseWriter, r *http.Request) { 15 | name := r.PathValue("name") 16 | name = filepath.Clean(name) 17 | 18 | repo, err := securejoin.SecureJoin(d.c.Repo.ScanPath, name) 19 | if err != nil { 20 | log.Printf("securejoin error: %v", err) 21 | d.Write404(w) 22 | return 23 | } 24 | 25 | w.Header().Set("content-type", "application/x-git-upload-pack-advertisement") 26 | w.WriteHeader(http.StatusOK) 27 | 28 | cmd := service.ServiceCommand{ 29 | Dir: repo, 30 | Stdout: w, 31 | } 32 | 33 | if err := cmd.InfoRefs(); err != nil { 34 | http.Error(w, err.Error(), 500) 35 | log.Printf("git: failed to execute git-upload-pack (info/refs) %s", err) 36 | return 37 | } 38 | } 39 | 40 | func (d *deps) UploadPack(w http.ResponseWriter, r *http.Request) { 41 | name := r.PathValue("name") 42 | name = filepath.Clean(name) 43 | 44 | repo, err := securejoin.SecureJoin(d.c.Repo.ScanPath, name) 45 | if err != nil { 46 | log.Printf("securejoin error: %v", err) 47 | d.Write404(w) 48 | return 49 | } 50 | 51 | w.Header().Set("content-type", "application/x-git-upload-pack-result") 52 | w.Header().Set("Connection", "Keep-Alive") 53 | w.Header().Set("Transfer-Encoding", "chunked") 54 | w.WriteHeader(http.StatusOK) 55 | 56 | cmd := service.ServiceCommand{ 57 | Dir: repo, 58 | Stdout: w, 59 | } 60 | 61 | var reader io.ReadCloser 62 | reader = r.Body 63 | 64 | if r.Header.Get("Content-Encoding") == "gzip" { 65 | reader, err := gzip.NewReader(r.Body) 66 | if err != nil { 67 | http.Error(w, err.Error(), 500) 68 | log.Printf("git: failed to create gzip reader: %s", err) 69 | return 70 | } 71 | defer reader.Close() 72 | } 73 | 74 | cmd.Stdin = reader 75 | if err := cmd.UploadPack(); err != nil { 76 | http.Error(w, err.Error(), 500) 77 | log.Printf("git: failed to execute git-upload-pack %s", err) 78 | return 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /routes/handler.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "net/http" 5 | 6 | "git.icyphox.sh/legit/config" 7 | ) 8 | 9 | // Checks for gitprotocol-http(5) specific smells; if found, passes 10 | // the request on to the git http service, else render the web frontend. 11 | func (d *deps) Multiplex(w http.ResponseWriter, r *http.Request) { 12 | path := r.PathValue("rest") 13 | 14 | if r.URL.RawQuery == "service=git-receive-pack" { 15 | w.WriteHeader(http.StatusBadRequest) 16 | w.Write([]byte("no pushing allowed!")) 17 | return 18 | } 19 | 20 | if path == "info/refs" && 21 | r.URL.RawQuery == "service=git-upload-pack" && 22 | r.Method == "GET" { 23 | d.InfoRefs(w, r) 24 | } else if path == "git-upload-pack" && r.Method == "POST" { 25 | d.UploadPack(w, r) 26 | } else if r.Method == "GET" { 27 | d.RepoIndex(w, r) 28 | } 29 | } 30 | 31 | func Handlers(c *config.Config) *http.ServeMux { 32 | mux := http.NewServeMux() 33 | d := deps{c} 34 | 35 | mux.HandleFunc("GET /", d.Index) 36 | mux.HandleFunc("GET /static/{file}", d.ServeStatic) 37 | mux.HandleFunc("GET /{name}", d.Multiplex) 38 | mux.HandleFunc("POST /{name}", d.Multiplex) 39 | mux.HandleFunc("GET /{name}/tree/{ref}/{rest...}", d.RepoTree) 40 | mux.HandleFunc("GET /{name}/blob/{ref}/{rest...}", d.FileContent) 41 | mux.HandleFunc("GET /{name}/log/{ref}", d.Log) 42 | mux.HandleFunc("GET /{name}/archive/{file}", d.Archive) 43 | mux.HandleFunc("GET /{name}/commit/{ref}", d.Diff) 44 | mux.HandleFunc("GET /{name}/refs/{$}", d.Refs) 45 | mux.HandleFunc("GET /{name}/{rest...}", d.Multiplex) 46 | mux.HandleFunc("POST /{name}/{rest...}", d.Multiplex) 47 | 48 | return mux 49 | } 50 | -------------------------------------------------------------------------------- /routes/routes.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "compress/gzip" 5 | "fmt" 6 | "html/template" 7 | "log" 8 | "net/http" 9 | "os" 10 | "path/filepath" 11 | "sort" 12 | "strconv" 13 | "strings" 14 | "time" 15 | 16 | "git.icyphox.sh/legit/config" 17 | "git.icyphox.sh/legit/git" 18 | securejoin "github.com/cyphar/filepath-securejoin" 19 | "github.com/dustin/go-humanize" 20 | "github.com/microcosm-cc/bluemonday" 21 | "github.com/russross/blackfriday/v2" 22 | ) 23 | 24 | type deps struct { 25 | c *config.Config 26 | } 27 | 28 | func (d *deps) Index(w http.ResponseWriter, r *http.Request) { 29 | dirs, err := os.ReadDir(d.c.Repo.ScanPath) 30 | if err != nil { 31 | d.Write500(w) 32 | log.Printf("reading scan path: %s", err) 33 | return 34 | } 35 | 36 | type info struct { 37 | DisplayName, Name, Desc, Idle string 38 | d time.Time 39 | } 40 | 41 | infos := []info{} 42 | 43 | for _, dir := range dirs { 44 | name := dir.Name() 45 | if !dir.IsDir() || d.isIgnored(name) || d.isUnlisted(name) { 46 | continue 47 | } 48 | 49 | path, err := securejoin.SecureJoin(d.c.Repo.ScanPath, name) 50 | if err != nil { 51 | log.Printf("securejoin error: %v", err) 52 | d.Write404(w) 53 | return 54 | } 55 | 56 | gr, err := git.Open(path, "") 57 | if err != nil { 58 | log.Println(err) 59 | continue 60 | } 61 | 62 | c, err := gr.LastCommit() 63 | if err != nil { 64 | d.Write500(w) 65 | log.Println(err) 66 | return 67 | } 68 | 69 | infos = append(infos, info{ 70 | DisplayName: getDisplayName(name), 71 | Name: name, 72 | Desc: getDescription(path), 73 | Idle: humanize.Time(c.Author.When), 74 | d: c.Author.When, 75 | }) 76 | } 77 | 78 | sort.Slice(infos, func(i, j int) bool { 79 | return infos[j].d.Before(infos[i].d) 80 | }) 81 | 82 | tpath := filepath.Join(d.c.Dirs.Templates, "*") 83 | t := template.Must(template.ParseGlob(tpath)) 84 | 85 | data := make(map[string]interface{}) 86 | data["meta"] = d.c.Meta 87 | data["info"] = infos 88 | 89 | if err := t.ExecuteTemplate(w, "index", data); err != nil { 90 | log.Println(err) 91 | return 92 | } 93 | } 94 | 95 | func (d *deps) RepoIndex(w http.ResponseWriter, r *http.Request) { 96 | name := r.PathValue("name") 97 | if d.isIgnored(name) { 98 | d.Write404(w) 99 | return 100 | } 101 | name = filepath.Clean(name) 102 | path, err := securejoin.SecureJoin(d.c.Repo.ScanPath, name) 103 | if err != nil { 104 | log.Printf("securejoin error: %v", err) 105 | d.Write404(w) 106 | return 107 | } 108 | 109 | gr, err := git.Open(path, "") 110 | if err != nil { 111 | d.Write404(w) 112 | return 113 | } 114 | 115 | commits, err := gr.Commits() 116 | if err != nil { 117 | d.Write500(w) 118 | log.Println(err) 119 | return 120 | } 121 | 122 | var readmeContent template.HTML 123 | for _, readme := range d.c.Repo.Readme { 124 | ext := filepath.Ext(readme) 125 | content, _ := gr.FileContent(readme) 126 | if len(content) > 0 { 127 | switch ext { 128 | case ".md", ".mkd", ".markdown": 129 | unsafe := blackfriday.Run( 130 | []byte(content), 131 | blackfriday.WithExtensions(blackfriday.CommonExtensions), 132 | ) 133 | html := bluemonday.UGCPolicy().SanitizeBytes(unsafe) 134 | readmeContent = template.HTML(html) 135 | default: 136 | safe := bluemonday.UGCPolicy().SanitizeBytes([]byte(content)) 137 | readmeContent = template.HTML( 138 | fmt.Sprintf(`
%s
`, safe), 139 | ) 140 | } 141 | break 142 | } 143 | } 144 | 145 | if readmeContent == "" { 146 | log.Printf("no readme found for %s", name) 147 | } 148 | 149 | mainBranch, err := gr.FindMainBranch(d.c.Repo.MainBranch) 150 | if err != nil { 151 | d.Write500(w) 152 | log.Println(err) 153 | return 154 | } 155 | 156 | tpath := filepath.Join(d.c.Dirs.Templates, "*") 157 | t := template.Must(template.ParseGlob(tpath)) 158 | 159 | if len(commits) >= 3 { 160 | commits = commits[:3] 161 | } 162 | 163 | data := make(map[string]any) 164 | data["name"] = name 165 | data["displayname"] = getDisplayName(name) 166 | data["ref"] = mainBranch 167 | data["readme"] = readmeContent 168 | data["commits"] = commits 169 | data["desc"] = getDescription(path) 170 | data["servername"] = d.c.Server.Name 171 | data["meta"] = d.c.Meta 172 | data["gomod"] = isGoModule(gr) 173 | 174 | if err := t.ExecuteTemplate(w, "repo", data); err != nil { 175 | log.Println(err) 176 | return 177 | } 178 | 179 | return 180 | } 181 | 182 | func (d *deps) RepoTree(w http.ResponseWriter, r *http.Request) { 183 | name := r.PathValue("name") 184 | if d.isIgnored(name) { 185 | d.Write404(w) 186 | return 187 | } 188 | treePath := r.PathValue("rest") 189 | ref := r.PathValue("ref") 190 | 191 | name = filepath.Clean(name) 192 | path, err := securejoin.SecureJoin(d.c.Repo.ScanPath, name) 193 | if err != nil { 194 | log.Printf("securejoin error: %v", err) 195 | d.Write404(w) 196 | return 197 | } 198 | gr, err := git.Open(path, ref) 199 | if err != nil { 200 | d.Write404(w) 201 | return 202 | } 203 | 204 | files, err := gr.FileTree(treePath) 205 | if err != nil { 206 | d.Write500(w) 207 | log.Println(err) 208 | return 209 | } 210 | 211 | data := make(map[string]any) 212 | data["name"] = name 213 | data["displayname"] = getDisplayName(name) 214 | data["ref"] = ref 215 | data["parent"] = treePath 216 | data["desc"] = getDescription(path) 217 | data["dotdot"] = filepath.Dir(treePath) 218 | 219 | d.listFiles(files, data, w) 220 | return 221 | } 222 | 223 | func (d *deps) FileContent(w http.ResponseWriter, r *http.Request) { 224 | var raw bool 225 | if rawParam, err := strconv.ParseBool(r.URL.Query().Get("raw")); err == nil { 226 | raw = rawParam 227 | } 228 | 229 | name := r.PathValue("name") 230 | if d.isIgnored(name) { 231 | d.Write404(w) 232 | return 233 | } 234 | treePath := r.PathValue("rest") 235 | ref := r.PathValue("ref") 236 | 237 | name = filepath.Clean(name) 238 | path, err := securejoin.SecureJoin(d.c.Repo.ScanPath, name) 239 | if err != nil { 240 | log.Printf("securejoin error: %v", err) 241 | d.Write404(w) 242 | return 243 | } 244 | 245 | gr, err := git.Open(path, ref) 246 | if err != nil { 247 | d.Write404(w) 248 | return 249 | } 250 | 251 | contents, err := gr.FileContent(treePath) 252 | if err != nil { 253 | d.Write500(w) 254 | return 255 | } 256 | data := make(map[string]any) 257 | data["name"] = name 258 | data["displayname"] = getDisplayName(name) 259 | data["ref"] = ref 260 | data["desc"] = getDescription(path) 261 | data["path"] = treePath 262 | 263 | if raw { 264 | d.showRaw(contents, w) 265 | } else { 266 | if d.c.Meta.SyntaxHighlight == "" { 267 | d.showFile(contents, data, w) 268 | } else { 269 | d.showFileWithHighlight(treePath, contents, data, w) 270 | } 271 | } 272 | } 273 | 274 | func (d *deps) Archive(w http.ResponseWriter, r *http.Request) { 275 | name := r.PathValue("name") 276 | if d.isIgnored(name) { 277 | d.Write404(w) 278 | return 279 | } 280 | 281 | file := r.PathValue("file") 282 | 283 | // TODO: extend this to add more files compression (e.g.: xz) 284 | if !strings.HasSuffix(file, ".tar.gz") { 285 | d.Write404(w) 286 | return 287 | } 288 | 289 | ref := strings.TrimSuffix(file, ".tar.gz") 290 | 291 | // This allows the browser to use a proper name for the file when 292 | // downloading 293 | filename := fmt.Sprintf("%s-%s.tar.gz", name, ref) 294 | setContentDisposition(w, filename) 295 | setGZipMIME(w) 296 | 297 | path, err := securejoin.SecureJoin(d.c.Repo.ScanPath, name) 298 | if err != nil { 299 | log.Printf("securejoin error: %v", err) 300 | d.Write404(w) 301 | return 302 | } 303 | 304 | gr, err := git.Open(path, ref) 305 | if err != nil { 306 | d.Write404(w) 307 | return 308 | } 309 | 310 | gw := gzip.NewWriter(w) 311 | defer gw.Close() 312 | 313 | prefix := fmt.Sprintf("%s-%s", name, ref) 314 | err = gr.WriteTar(gw, prefix) 315 | if err != nil { 316 | // once we start writing to the body we can't report error anymore 317 | // so we are only left with printing the error. 318 | log.Println(err) 319 | return 320 | } 321 | 322 | err = gw.Flush() 323 | if err != nil { 324 | // once we start writing to the body we can't report error anymore 325 | // so we are only left with printing the error. 326 | log.Println(err) 327 | return 328 | } 329 | } 330 | 331 | func (d *deps) Log(w http.ResponseWriter, r *http.Request) { 332 | name := r.PathValue("name") 333 | if d.isIgnored(name) { 334 | d.Write404(w) 335 | return 336 | } 337 | ref := r.PathValue("ref") 338 | 339 | path, err := securejoin.SecureJoin(d.c.Repo.ScanPath, name) 340 | if err != nil { 341 | log.Printf("securejoin error: %v", err) 342 | d.Write404(w) 343 | return 344 | } 345 | 346 | gr, err := git.Open(path, ref) 347 | if err != nil { 348 | d.Write404(w) 349 | return 350 | } 351 | 352 | commits, err := gr.Commits() 353 | if err != nil { 354 | d.Write500(w) 355 | log.Println(err) 356 | return 357 | } 358 | 359 | tpath := filepath.Join(d.c.Dirs.Templates, "*") 360 | t := template.Must(template.ParseGlob(tpath)) 361 | 362 | data := make(map[string]interface{}) 363 | data["commits"] = commits 364 | data["meta"] = d.c.Meta 365 | data["name"] = name 366 | data["displayname"] = getDisplayName(name) 367 | data["ref"] = ref 368 | data["desc"] = getDescription(path) 369 | data["log"] = true 370 | 371 | if err := t.ExecuteTemplate(w, "log", data); err != nil { 372 | log.Println(err) 373 | return 374 | } 375 | } 376 | 377 | func (d *deps) Diff(w http.ResponseWriter, r *http.Request) { 378 | name := r.PathValue("name") 379 | if d.isIgnored(name) { 380 | d.Write404(w) 381 | return 382 | } 383 | ref := r.PathValue("ref") 384 | 385 | path, err := securejoin.SecureJoin(d.c.Repo.ScanPath, name) 386 | if err != nil { 387 | log.Printf("securejoin error: %v", err) 388 | d.Write404(w) 389 | return 390 | } 391 | gr, err := git.Open(path, ref) 392 | if err != nil { 393 | d.Write404(w) 394 | return 395 | } 396 | 397 | diff, err := gr.Diff() 398 | if err != nil { 399 | d.Write500(w) 400 | log.Println(err) 401 | return 402 | } 403 | 404 | tpath := filepath.Join(d.c.Dirs.Templates, "*") 405 | t := template.Must(template.ParseGlob(tpath)) 406 | 407 | data := make(map[string]interface{}) 408 | 409 | data["commit"] = diff.Commit 410 | data["stat"] = diff.Stat 411 | data["diff"] = diff.Diff 412 | data["meta"] = d.c.Meta 413 | data["name"] = name 414 | data["displayname"] = getDisplayName(name) 415 | data["ref"] = ref 416 | data["desc"] = getDescription(path) 417 | 418 | if err := t.ExecuteTemplate(w, "commit", data); err != nil { 419 | log.Println(err) 420 | return 421 | } 422 | } 423 | 424 | func (d *deps) Refs(w http.ResponseWriter, r *http.Request) { 425 | name := r.PathValue("name") 426 | if d.isIgnored(name) { 427 | d.Write404(w) 428 | return 429 | } 430 | 431 | path, err := securejoin.SecureJoin(d.c.Repo.ScanPath, name) 432 | if err != nil { 433 | log.Printf("securejoin error: %v", err) 434 | d.Write404(w) 435 | return 436 | } 437 | 438 | gr, err := git.Open(path, "") 439 | if err != nil { 440 | d.Write404(w) 441 | return 442 | } 443 | 444 | tags, err := gr.Tags() 445 | if err != nil { 446 | // Non-fatal, we *should* have at least one branch to show. 447 | log.Println(err) 448 | } 449 | 450 | branches, err := gr.Branches() 451 | if err != nil { 452 | log.Println(err) 453 | d.Write500(w) 454 | return 455 | } 456 | 457 | tpath := filepath.Join(d.c.Dirs.Templates, "*") 458 | t := template.Must(template.ParseGlob(tpath)) 459 | 460 | data := make(map[string]interface{}) 461 | 462 | data["meta"] = d.c.Meta 463 | data["name"] = name 464 | data["displayname"] = getDisplayName(name) 465 | data["branches"] = branches 466 | data["tags"] = tags 467 | data["desc"] = getDescription(path) 468 | 469 | if err := t.ExecuteTemplate(w, "refs", data); err != nil { 470 | log.Println(err) 471 | return 472 | } 473 | } 474 | 475 | func (d *deps) ServeStatic(w http.ResponseWriter, r *http.Request) { 476 | f := r.PathValue("file") 477 | f = filepath.Clean(f) 478 | f, err := securejoin.SecureJoin(d.c.Dirs.Static, f) 479 | if err != nil { 480 | d.Write404(w) 481 | return 482 | } 483 | 484 | http.ServeFile(w, r, f) 485 | } 486 | -------------------------------------------------------------------------------- /routes/template.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "bytes" 5 | "html/template" 6 | "io" 7 | "log" 8 | "net/http" 9 | "path/filepath" 10 | "strings" 11 | 12 | "git.icyphox.sh/legit/git" 13 | "github.com/alecthomas/chroma/v2/formatters/html" 14 | "github.com/alecthomas/chroma/v2/lexers" 15 | "github.com/alecthomas/chroma/v2/styles" 16 | ) 17 | 18 | func (d *deps) Write404(w http.ResponseWriter) { 19 | tpath := filepath.Join(d.c.Dirs.Templates, "*") 20 | t := template.Must(template.ParseGlob(tpath)) 21 | w.WriteHeader(404) 22 | if err := t.ExecuteTemplate(w, "404", nil); err != nil { 23 | log.Printf("404 template: %s", err) 24 | } 25 | } 26 | 27 | func (d *deps) Write500(w http.ResponseWriter) { 28 | tpath := filepath.Join(d.c.Dirs.Templates, "*") 29 | t := template.Must(template.ParseGlob(tpath)) 30 | w.WriteHeader(500) 31 | if err := t.ExecuteTemplate(w, "500", nil); err != nil { 32 | log.Printf("500 template: %s", err) 33 | } 34 | } 35 | 36 | func (d *deps) listFiles(files []git.NiceTree, data map[string]any, w http.ResponseWriter) { 37 | tpath := filepath.Join(d.c.Dirs.Templates, "*") 38 | t := template.Must(template.ParseGlob(tpath)) 39 | 40 | data["files"] = files 41 | data["meta"] = d.c.Meta 42 | 43 | if err := t.ExecuteTemplate(w, "tree", data); err != nil { 44 | log.Println(err) 45 | return 46 | } 47 | } 48 | 49 | func countLines(r io.Reader) (int, error) { 50 | buf := make([]byte, 32*1024) 51 | bufLen := 0 52 | count := 0 53 | nl := []byte{'\n'} 54 | 55 | for { 56 | c, err := r.Read(buf) 57 | if c > 0 { 58 | bufLen += c 59 | } 60 | count += bytes.Count(buf[:c], nl) 61 | 62 | switch { 63 | case err == io.EOF: 64 | /* handle last line not having a newline at the end */ 65 | if bufLen >= 1 && buf[(bufLen-1)%(32*1024)] != '\n' { 66 | count++ 67 | } 68 | return count, nil 69 | case err != nil: 70 | return 0, err 71 | } 72 | } 73 | } 74 | 75 | func (d *deps) showFileWithHighlight(name, content string, data map[string]any, w http.ResponseWriter) { 76 | tpath := filepath.Join(d.c.Dirs.Templates, "*") 77 | t := template.Must(template.ParseGlob(tpath)) 78 | 79 | lexer := lexers.Get(name) 80 | if lexer == nil { 81 | lexer = lexers.Get(".txt") 82 | } 83 | 84 | style := styles.Get(d.c.Meta.SyntaxHighlight) 85 | if style == nil { 86 | style = styles.Get("monokailight") 87 | } 88 | 89 | formatter := html.New( 90 | html.WithLineNumbers(true), 91 | html.WithLinkableLineNumbers(true, "L"), 92 | ) 93 | 94 | iterator, err := lexer.Tokenise(nil, content) 95 | if err != nil { 96 | d.Write500(w) 97 | return 98 | } 99 | 100 | var code bytes.Buffer 101 | err = formatter.Format(&code, style, iterator) 102 | if err != nil { 103 | d.Write500(w) 104 | return 105 | } 106 | 107 | data["content"] = template.HTML(code.String()) 108 | data["meta"] = d.c.Meta 109 | data["chroma"] = true 110 | 111 | if err := t.ExecuteTemplate(w, "file", data); err != nil { 112 | log.Println(err) 113 | return 114 | } 115 | } 116 | 117 | func (d *deps) showFile(content string, data map[string]any, w http.ResponseWriter) { 118 | tpath := filepath.Join(d.c.Dirs.Templates, "*") 119 | t := template.Must(template.ParseGlob(tpath)) 120 | 121 | lc, err := countLines(strings.NewReader(content)) 122 | if err != nil { 123 | // Non-fatal, we'll just skip showing line numbers in the template. 124 | log.Printf("counting lines: %s", err) 125 | } 126 | 127 | lines := make([]int, lc) 128 | if lc > 0 { 129 | for i := range lines { 130 | lines[i] = i + 1 131 | } 132 | } 133 | 134 | data["linecount"] = lines 135 | data["content"] = content 136 | data["meta"] = d.c.Meta 137 | data["chroma"] = false 138 | 139 | if err := t.ExecuteTemplate(w, "file", data); err != nil { 140 | log.Println(err) 141 | return 142 | } 143 | } 144 | 145 | func (d *deps) showRaw(content string, w http.ResponseWriter) { 146 | w.WriteHeader(http.StatusOK) 147 | w.Header().Set("Content-Type", "text/plain") 148 | w.Write([]byte(content)) 149 | return 150 | } 151 | -------------------------------------------------------------------------------- /routes/util.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "io/fs" 5 | "log" 6 | "net/http" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | 11 | "git.icyphox.sh/legit/git" 12 | ) 13 | 14 | func isGoModule(gr *git.GitRepo) bool { 15 | _, err := gr.FileContent("go.mod") 16 | return err == nil 17 | } 18 | 19 | func getDisplayName(name string) string { 20 | return strings.TrimSuffix(name, ".git") 21 | } 22 | 23 | func getDescription(path string) (desc string) { 24 | db, err := os.ReadFile(filepath.Join(path, "description")) 25 | if err == nil { 26 | desc = string(db) 27 | } else { 28 | desc = "" 29 | } 30 | return 31 | } 32 | 33 | func (d *deps) isUnlisted(name string) bool { 34 | for _, i := range d.c.Repo.Unlisted { 35 | if name == i { 36 | return true 37 | } 38 | } 39 | 40 | return false 41 | } 42 | 43 | func (d *deps) isIgnored(name string) bool { 44 | for _, i := range d.c.Repo.Ignore { 45 | if name == i { 46 | return true 47 | } 48 | } 49 | 50 | return false 51 | } 52 | 53 | type repoInfo struct { 54 | Git *git.GitRepo 55 | Path string 56 | Category string 57 | } 58 | 59 | func (d *deps) getAllRepos() ([]repoInfo, error) { 60 | repos := []repoInfo{} 61 | max := strings.Count(d.c.Repo.ScanPath, string(os.PathSeparator)) + 2 62 | 63 | err := filepath.WalkDir(d.c.Repo.ScanPath, func(path string, de fs.DirEntry, err error) error { 64 | if err != nil { 65 | return err 66 | } 67 | 68 | if de.IsDir() { 69 | // Check if we've exceeded our recursion depth 70 | if strings.Count(path, string(os.PathSeparator)) > max { 71 | return fs.SkipDir 72 | } 73 | 74 | if d.isIgnored(path) { 75 | return fs.SkipDir 76 | } 77 | 78 | // A bare repo should always have at least a HEAD file, if it 79 | // doesn't we can continue recursing 80 | if _, err := os.Lstat(filepath.Join(path, "HEAD")); err == nil { 81 | repo, err := git.Open(path, "") 82 | if err != nil { 83 | log.Println(err) 84 | } else { 85 | relpath, _ := filepath.Rel(d.c.Repo.ScanPath, path) 86 | repos = append(repos, repoInfo{ 87 | Git: repo, 88 | Path: relpath, 89 | Category: d.category(path), 90 | }) 91 | // Since we found a Git repo, we don't want to recurse 92 | // further 93 | return fs.SkipDir 94 | } 95 | } 96 | } 97 | return nil 98 | }) 99 | 100 | return repos, err 101 | } 102 | 103 | func (d *deps) category(path string) string { 104 | return strings.TrimPrefix(filepath.Dir(strings.TrimPrefix(path, d.c.Repo.ScanPath)), string(os.PathSeparator)) 105 | } 106 | 107 | func setContentDisposition(w http.ResponseWriter, name string) { 108 | h := "inline; filename=\"" + name + "\"" 109 | w.Header().Add("Content-Disposition", h) 110 | } 111 | 112 | func setGZipMIME(w http.ResponseWriter) { 113 | setMIME(w, "application/gzip") 114 | } 115 | 116 | func setMIME(w http.ResponseWriter, mime string) { 117 | w.Header().Add("Content-Type", mime) 118 | } 119 | -------------------------------------------------------------------------------- /static/legit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/icyphox/legit/5acac24dede0143e6415d83d94a66017fd3c2692/static/legit.png -------------------------------------------------------------------------------- /static/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --white: #fff; 3 | --light: #f4f4f4; 4 | --cyan: #509c93; 5 | --light-gray: #eee; 6 | --medium-gray: #ddd; 7 | --gray: #6a6a6a; 8 | --dark: #444; 9 | --darker: #222; 10 | 11 | --sans-font: -apple-system, BlinkMacSystemFont, "Inter", "Roboto", "Segoe UI", sans-serif; 12 | --display-font: -apple-system, BlinkMacSystemFont, "Inter", "Roboto", "Segoe UI", sans-serif; 13 | --mono-font: 'SF Mono', SFMono-Regular, ui-monospace, 'DejaVu Sans Mono', 'Roboto Mono', Menlo, Consolas, monospace; 14 | } 15 | 16 | @media (prefers-color-scheme: dark) { 17 | :root { 18 | color-scheme: dark light; 19 | --light: #181818; 20 | --cyan: #76c7c0; 21 | --light-gray: #333; 22 | --medium-gray: #444; 23 | --gray: #aaa; 24 | --dark: #ddd; 25 | --darker: #f4f4f4; 26 | --white: #000; 27 | } 28 | } 29 | 30 | html { 31 | background: var(--white); 32 | -webkit-text-size-adjust: none; 33 | font-family: var(--sans-font); 34 | font-weight: 380; 35 | } 36 | 37 | pre { 38 | font-family: var(--mono-font); 39 | } 40 | 41 | ::selection { 42 | background: var(--medium-gray); 43 | opacity: 0.3; 44 | } 45 | 46 | * { 47 | box-sizing: border-box; 48 | padding: 0; 49 | margin: 0; 50 | } 51 | 52 | body { 53 | max-width: 1000px; 54 | padding: 0 13px; 55 | margin: 40px auto; 56 | } 57 | 58 | main, footer { 59 | font-size: 1rem; 60 | padding: 0; 61 | line-height: 160%; 62 | } 63 | 64 | header h1, h2, h3 { 65 | font-family: var(--display-font); 66 | } 67 | 68 | h2 { 69 | font-weight: 400; 70 | } 71 | 72 | strong { 73 | font-weight: 500; 74 | } 75 | 76 | main h1 { 77 | padding: 10px 0 10px 0; 78 | } 79 | 80 | main h2 { 81 | font-size: 18px; 82 | } 83 | 84 | main h2, h3 { 85 | padding: 20px 0 15px 0; 86 | } 87 | 88 | nav { 89 | padding: 0.4rem 0 1.5rem 0; 90 | } 91 | 92 | nav ul { 93 | padding: 0; 94 | margin: 0; 95 | list-style: none; 96 | padding-bottom: 20px; 97 | } 98 | 99 | nav ul li { 100 | padding-right: 10px; 101 | display: inline-block; 102 | } 103 | 104 | a { 105 | margin: 0; 106 | padding: 0; 107 | box-sizing: border-box; 108 | text-decoration: none; 109 | word-wrap: break-word; 110 | } 111 | 112 | a { 113 | color: var(--darker); 114 | border-bottom: 1.5px solid var(--medium-gray); 115 | } 116 | 117 | a:hover { 118 | border-bottom: 1.5px solid var(--gray); 119 | } 120 | 121 | .index { 122 | padding-top: 2em; 123 | display: grid; 124 | grid-template-columns: 6em 1fr minmax(0, 7em); 125 | grid-row-gap: 0.5em; 126 | min-width: 0; 127 | } 128 | 129 | .clone-url { 130 | padding-top: 2rem; 131 | } 132 | 133 | .clone-url pre { 134 | color: var(--dark); 135 | white-space: pre-wrap; 136 | } 137 | 138 | .desc { 139 | font-weight: normal; 140 | color: var(--gray); 141 | font-style: italic; 142 | } 143 | 144 | .tree { 145 | display: grid; 146 | grid-template-columns: 10ch auto 1fr; 147 | grid-row-gap: 0.5em; 148 | grid-column-gap: 1em; 149 | min-width: 0; 150 | } 151 | 152 | .log { 153 | display: grid; 154 | grid-template-columns: 20rem minmax(0, 1fr); 155 | grid-row-gap: 0.8em; 156 | grid-column-gap: 8rem; 157 | margin-bottom: 2em; 158 | padding-bottom: 1em; 159 | border-bottom: 1.5px solid var(--medium-gray); 160 | } 161 | 162 | .log pre { 163 | white-space: pre-wrap; 164 | } 165 | 166 | .mode, .size { 167 | font-family: var(--mono-font); 168 | } 169 | .size { 170 | text-align: right; 171 | } 172 | 173 | .readme pre { 174 | white-space: pre-wrap; 175 | overflow-x: auto; 176 | } 177 | 178 | .readme { 179 | background: var(--light-gray); 180 | padding: 0.5rem; 181 | } 182 | 183 | .readme ul { 184 | padding: revert; 185 | } 186 | 187 | .readme img { 188 | max-width: 100%; 189 | } 190 | 191 | .diff { 192 | margin: 1rem 0 1rem 0; 193 | padding: 1rem 0 1rem 0; 194 | border-bottom: 1.5px solid var(--medium-gray); 195 | } 196 | 197 | .diff pre { 198 | overflow: scroll; 199 | } 200 | 201 | .diff-stat { 202 | padding: 1rem 0 1rem 0; 203 | } 204 | 205 | .commit-hash, .commit-email { 206 | font-family: var(--mono-font); 207 | } 208 | 209 | .commit-email:before { 210 | content: '<'; 211 | } 212 | 213 | .commit-email:after { 214 | content: '>'; 215 | } 216 | 217 | .commit { 218 | margin-bottom: 1rem; 219 | } 220 | 221 | .commit pre { 222 | padding-bottom: 1rem; 223 | white-space: pre-wrap; 224 | } 225 | 226 | .diff-stat ul li { 227 | list-style: none; 228 | padding-left: 0.5em; 229 | } 230 | 231 | .diff-add { 232 | color: green; 233 | } 234 | 235 | .diff-del { 236 | color: red; 237 | } 238 | 239 | .diff-noop { 240 | color: var(--gray); 241 | } 242 | 243 | .ref { 244 | font-family: var(--sans-font); 245 | font-size: 14px; 246 | color: var(--gray); 247 | display: inline-block; 248 | padding-top: 0.7em; 249 | } 250 | 251 | .refs pre { 252 | white-space: pre-wrap; 253 | padding-bottom: 0.5rem; 254 | } 255 | 256 | .refs strong { 257 | padding-right: 1em; 258 | } 259 | 260 | .line-numbers { 261 | white-space: pre-line; 262 | -moz-user-select: -moz-none; 263 | -khtml-user-select: none; 264 | -webkit-user-select: none; 265 | -o-user-select: none; 266 | user-select: none; 267 | display: flex; 268 | float: left; 269 | flex-direction: column; 270 | margin-right: 1ch; 271 | } 272 | 273 | .file-wrapper { 274 | display: flex; 275 | flex-direction: row; 276 | grid-template-columns: 1rem minmax(0, 1fr); 277 | gap: 1rem; 278 | padding: 0.5rem; 279 | background: var(--light-gray); 280 | overflow-x: auto; 281 | } 282 | 283 | .chroma-file-wrapper { 284 | display: flex; 285 | flex-direction: row; 286 | grid-template-columns: 1rem minmax(0, 1fr); 287 | overflow-x: auto; 288 | } 289 | 290 | .file-content { 291 | background: var(--light-gray); 292 | overflow-y: hidden; 293 | overflow-x: auto; 294 | } 295 | 296 | .diff-type { 297 | color: var(--gray); 298 | } 299 | 300 | .commit-info { 301 | color: var(--gray); 302 | padding-bottom: 1.5rem; 303 | font-size: 0.85rem; 304 | } 305 | 306 | @media (max-width: 600px) { 307 | .index { 308 | grid-row-gap: 0.8em; 309 | } 310 | 311 | .log { 312 | grid-template-columns: 1fr; 313 | grid-row-gap: 0em; 314 | } 315 | 316 | .index { 317 | grid-template-columns: 1fr; 318 | grid-row-gap: 0em; 319 | } 320 | 321 | .index-name:not(:first-child) { 322 | padding-top: 1.5rem; 323 | } 324 | 325 | .commit-info:not(:last-child) { 326 | padding-bottom: 1.5rem; 327 | } 328 | 329 | pre { 330 | font-size: 0.8rem; 331 | } 332 | } 333 | -------------------------------------------------------------------------------- /templates/404.html: -------------------------------------------------------------------------------- 1 | {{ define "404" }} 2 | 3 | 404 4 | {{ template "head" . }} 5 | 6 | {{ template "nav" . }} 7 |
8 |

404 — nothing like that here.

9 |
10 | 11 | 12 | 13 | {{ end }} 14 | -------------------------------------------------------------------------------- /templates/500.html: -------------------------------------------------------------------------------- 1 | {{ define "500" }} 2 | 3 | 500 4 | {{ template "head" . }} 5 | 6 | {{ template "nav" . }} 7 |
8 |

500 — something broke!

9 |
10 | 11 | 12 | 13 | {{ end }} 14 | -------------------------------------------------------------------------------- /templates/commit.html: -------------------------------------------------------------------------------- 1 | {{ define "commit" }} 2 | 3 | {{ template "head" . }} 4 | 5 | {{ template "repoheader" . }} 6 | 7 | {{ template "nav" . }} 8 |
9 |
10 |
 11 |           {{- .commit.Message -}}
 12 |         
13 |
14 | {{ .commit.Author.Name }} {{ .commit.Author.Email}} 15 |
{{ .commit.Author.When.Format "Mon, 02 Jan 2006 15:04:05 -0700" }}
16 |
17 | 18 |
19 | commit 20 |

21 | {{ .commit.This }} 22 | 23 |

24 |
25 | 26 | {{ if .commit.Parent }} 27 |
28 | parent 29 |

30 | {{ .commit.Parent }} 31 |

32 |
33 | 34 | {{ end }} 35 |
36 |
37 | {{ .stat.FilesChanged }} files changed, 38 | {{ .stat.Insertions }} insertions(+), 39 | {{ .stat.Deletions }} deletions(-) 40 |
41 |
42 |
43 | jump to 44 | {{ range .diff }} 45 | 48 | {{ end }} 49 |
50 |
51 |
52 |
53 | {{ $repo := .name }} 54 | {{ $this := .commit.This }} 55 | {{ $parent := .commit.Parent }} 56 | {{ range .diff }} 57 |
58 |
59 | {{ if .IsNew }} 60 | A 61 | {{ end }} 62 | {{ if .IsDelete }} 63 | D 64 | {{ end }} 65 | {{ if not (or .IsNew .IsDelete) }} 66 | M 67 | {{ end }} 68 | {{ if .Name.Old }} 69 | {{ .Name.Old }} 70 | {{ if .Name.New }} 71 | → 72 | {{ .Name.New }} 73 | {{ end }} 74 | {{ else }} 75 | {{ .Name.New }} 76 | {{- end -}} 77 | {{ if .IsBinary }} 78 |

Not showing binary file.

79 | {{ else }} 80 |
 81 |             {{- range .TextFragments -}}
 82 |             

{{- .Header -}}

83 | {{- range .Lines -}} 84 | {{- if eq .Op.String "+" -}} 85 | {{ .String }} 86 | {{- end -}} 87 | {{- if eq .Op.String "-" -}} 88 | {{ .String }} 89 | {{- end -}} 90 | {{- if eq .Op.String " " -}} 91 | {{ .String }} 92 | {{- end -}} 93 | {{- end -}} 94 | {{- end -}} 95 | {{- end -}} 96 |
97 |
98 |
99 | {{ end }} 100 |
101 |
102 | 103 | 104 | {{ end }} 105 | -------------------------------------------------------------------------------- /templates/file.html: -------------------------------------------------------------------------------- 1 | {{ define "file" }} 2 | 3 | {{ template "head" . }} 4 | {{ template "repoheader" . }} 5 | 6 | {{ template "nav" . }} 7 |
8 |

{{ .path }} (view raw)

9 | {{if .chroma }} 10 |
11 | {{ .content }} 12 |
13 | {{else}} 14 |
15 | 16 | 17 | 24 | 29 | 30 |
18 |
19 |             {{- range .linecount }}
20 |  {{ . }}
21 |             {{- end -}}
22 |               
23 |
25 |
26 |              {{- .content -}}
27 |               
28 |
31 |
32 | {{end}} 33 |
34 | 35 | 36 | {{ end }} 37 | -------------------------------------------------------------------------------- /templates/head.html: -------------------------------------------------------------------------------- 1 | {{ define "head" }} 2 | 3 | 4 | 5 | 6 | 7 | {{ if .parent }} 8 | {{ .meta.Title }} — {{ .name }} ({{ .ref }}): {{ .parent }}/ 9 | 10 | {{ else if .path }} 11 | {{ .meta.Title }} — {{ .name }} ({{ .ref }}): {{ .path }} 12 | {{ else if .files }} 13 | {{ .meta.Title }} — {{ .name }} ({{ .ref }}) 14 | {{ else if .commit }} 15 | {{ .meta.Title }} — {{ .name }}: {{ .commit.This }} 16 | {{ else if .branches }} 17 | {{ .meta.Title }} — {{ .name }}: refs 18 | {{ else if .commits }} 19 | {{ if .log }} 20 | {{ .meta.Title }} — {{ .name }}: log 21 | {{ else }} 22 | {{ .meta.Title }} — {{ .name }} 23 | {{ end }} 24 | {{ else }} 25 | {{ .meta.Title }} 26 | {{ end }} 27 | {{ if and .servername .gomod }} 28 | 29 | {{ end }} 30 | 31 | 32 | {{ end }} 33 | -------------------------------------------------------------------------------- /templates/index.html: -------------------------------------------------------------------------------- 1 | {{ define "index" }} 2 | 3 | {{ template "head" . }} 4 | 5 |
6 |

{{ .meta.Title }}

7 |

{{ .meta.Description }}

8 |
9 | 10 |
11 |
12 | {{ range .info }} 13 | 14 |
{{ .Desc }}
15 |
{{ .Idle }}
16 | {{ end }} 17 |
18 |
19 | 20 | 21 | {{ end }} 22 | -------------------------------------------------------------------------------- /templates/log.html: -------------------------------------------------------------------------------- 1 | {{ define "log" }} 2 | 3 | {{ template "head" . }} 4 | 5 | {{ template "repoheader" . }} 6 | 7 | {{ template "nav" . }} 8 |
9 | {{ $repo := .name }} 10 |
11 | {{ range .commits }} 12 |
13 |
{{ slice .Hash.String 0 8 }}
14 |
{{ .Message }}
15 |
16 |
17 | {{ .Author.Name }} {{ .Author.Email }} 18 |
{{ .Author.When.Format "Mon, 02 Jan 2006 15:04:05 -0700" }}
19 |
20 | {{ end }} 21 |
22 |
23 | 24 | 25 | {{ end }} 26 | -------------------------------------------------------------------------------- /templates/nav.html: -------------------------------------------------------------------------------- 1 | {{ define "nav" }} 2 | 14 | {{ end }} 15 | -------------------------------------------------------------------------------- /templates/refs.html: -------------------------------------------------------------------------------- 1 | {{ define "refs" }} 2 | 3 | {{ template "head" . }} 4 | 5 | {{ template "repoheader" . }} 6 | 7 | {{ template "nav" . }} 8 |
9 | {{ $name := .name }} 10 |

branches

11 |
12 | {{ range .branches }} 13 |
14 | {{ .Name.Short }} 15 | browse 16 | log 17 | tar.gz 18 |
19 | {{ end }} 20 |
21 | {{ if .tags }} 22 |

tags

23 |
24 | {{ range .tags }} 25 |
26 | {{ .Name }} 27 | browse 28 | log 29 | tar.gz 30 | {{ if .Message }} 31 |
{{ .Message }}
32 |
33 | {{ end }} 34 | {{ end }} 35 |
36 | {{ end }} 37 |
38 | 39 | 40 | {{ end }} 41 | -------------------------------------------------------------------------------- /templates/repo-header.html: -------------------------------------------------------------------------------- 1 | {{ define "repoheader" }} 2 |
3 |

4 | all repos 5 | — {{ .displayname }} 6 | {{ if .ref }} 7 | @ {{ .ref }} 8 | {{ end }} 9 |

10 |

{{ .desc }}

11 |
12 | {{ end }} 13 | -------------------------------------------------------------------------------- /templates/repo.html: -------------------------------------------------------------------------------- 1 | {{ define "repo" }} 2 | 3 | {{ template "head" . }} 4 | 5 | {{ template "repoheader" . }} 6 | 7 | 8 | {{ template "nav" . }} 9 |
10 | {{ $repo := .name }} 11 |
12 | {{ range .commits }} 13 |
14 |
{{ slice .Hash.String 0 8 }}
15 |
{{ .Message }}
16 |
17 |
18 | {{ .Author.Name }} {{ .Author.Email }} 19 |
{{ .Author.When.Format "Mon, 02 Jan 2006 15:04:05 -0700" }}
20 |
21 | {{ end }} 22 |
23 | {{- if .readme }} 24 |
25 | {{- .readme -}} 26 |
27 | {{- end -}} 28 | 29 |
30 | clone 31 |
32 | git clone https://{{ .servername }}/{{ .name }}
33 |         
34 |
35 |
36 | 37 | 38 | {{ end }} 39 | -------------------------------------------------------------------------------- /templates/tree.html: -------------------------------------------------------------------------------- 1 | {{ define "tree" }} 2 | 3 | 4 | {{ template "head" . }} 5 | 6 | {{ template "repoheader" . }} 7 | 8 | {{ template "nav" . }} 9 |
10 | {{ $repo := .name }} 11 | {{ $ref := .ref }} 12 | {{ $parent := .parent }} 13 | 14 |
15 | {{ if $parent }} 16 |
17 |
18 |
..
19 | {{ end }} 20 | {{ range .files }} 21 | {{ if not .IsFile }} 22 |
{{ .Mode }}
23 |
{{ .Size }}
24 |
25 | {{ if $parent }} 26 | {{ .Name }}/ 27 | {{ else }} 28 | {{ .Name }}/ 29 | {{ end }} 30 |
31 | {{ end }} 32 | {{ end }} 33 | {{ range .files }} 34 | {{ if .IsFile }} 35 |
{{ .Mode }}
36 |
{{ .Size }}
37 |
38 | {{ if $parent }} 39 | {{ .Name }} 40 | {{ else }} 41 | {{ .Name }} 42 | {{ end }} 43 |
44 | {{ end }} 45 | {{ end }} 46 |
47 |
48 |
49 |           {{- if .readme }}{{ .readme }}{{- end -}}
50 |         
51 |
52 |
53 | 54 | 55 | {{ end }} 56 | -------------------------------------------------------------------------------- /unveil.go: -------------------------------------------------------------------------------- 1 | //go:build openbsd 2 | // +build openbsd 3 | 4 | package main 5 | 6 | import ( 7 | "golang.org/x/sys/unix" 8 | "log" 9 | ) 10 | 11 | func Unveil(path string, perms string) error { 12 | log.Printf("unveil: \"%s\", %s", path, perms) 13 | return unix.Unveil(path, perms) 14 | } 15 | 16 | func UnveilBlock() error { 17 | log.Printf("unveil: block") 18 | return unix.UnveilBlock() 19 | } 20 | 21 | func UnveilPaths(paths []string, perms string) error { 22 | for _, path := range paths { 23 | if err := Unveil(path, perms); err != nil { 24 | return err 25 | } 26 | } 27 | return UnveilBlock() 28 | } 29 | -------------------------------------------------------------------------------- /unveil_stub.go: -------------------------------------------------------------------------------- 1 | //go:build !openbsd 2 | // +build !openbsd 3 | 4 | // Stub functions for GOOS that don't support unix.Unveil() 5 | 6 | package main 7 | 8 | func Unveil(path string, perms string) error { 9 | return nil 10 | } 11 | 12 | func UnveilBlock() error { 13 | return nil 14 | } 15 | 16 | func UnveilPaths(paths []string, perms string) error { 17 | return nil 18 | } 19 | --------------------------------------------------------------------------------