Monorepo for Tangled tangled.org

knotserver: init

+41
cmd/knotserver/main.go
··· 1 + package main 2 + 3 + import ( 4 + "flag" 5 + "fmt" 6 + "log" 7 + "log/slog" 8 + "net/http" 9 + "os" 10 + 11 + "github.com/icyphox/bild/config" 12 + "github.com/icyphox/bild/db" 13 + "github.com/icyphox/bild/routes" 14 + ) 15 + 16 + func main() { 17 + var cfg string 18 + flag.StringVar(&cfg, "config", "./config.yaml", "path to config file") 19 + flag.Parse() 20 + 21 + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, nil))) 22 + 23 + c, err := config.Read(cfg) 24 + if err != nil { 25 + log.Fatal(err) 26 + } 27 + db, err := db.Setup(c.Server.DBPath) 28 + if err != nil { 29 + log.Fatalf("failed to setup db: %s", err) 30 + } 31 + 32 + mux, err := routes.Setup(c, db) 33 + if err != nil { 34 + log.Fatal(err) 35 + } 36 + 37 + addr := fmt.Sprintf("%s:%d", c.Server.Host, c.Server.Port) 38 + 39 + log.Println("starting main server on", addr) 40 + go http.ListenAndServe(addr, mux) 41 + }
+1
go.mod
··· 9 9 github.com/bluekeyes/go-gitdiff v0.8.0 10 10 github.com/bluesky-social/indigo v0.0.0-20250123072624-9e3b84fdbb20 11 11 github.com/dustin/go-humanize v1.0.1 12 + github.com/go-chi/chi v1.5.5 12 13 github.com/go-chi/chi/v5 v5.2.0 13 14 github.com/go-git/go-git/v5 v5.12.0 14 15 github.com/google/uuid v1.6.0
+2
go.sum
··· 53 53 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 54 54 github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY= 55 55 github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4= 56 + github.com/go-chi/chi v1.5.5 h1:vOB/HbEMt9QqBqErz07QehcOKHaWFtuj87tTDVz2qXE= 57 + github.com/go-chi/chi v1.5.5/go.mod h1:C9JqLr3tIYjDOZpzn+BCuxY8z8vmca43EeMgyZt7irw= 56 58 github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0= 57 59 github.com/go-chi/chi/v5 v5.2.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= 58 60 github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E=
+74
knotserver/file.go
··· 1 + package knotserver 2 + 3 + import ( 4 + "bytes" 5 + "io" 6 + "log" 7 + "net/http" 8 + "strings" 9 + 10 + "github.com/icyphox/bild/git" 11 + ) 12 + 13 + func (h *Handle) listFiles(files []git.NiceTree, data map[string]any, w http.ResponseWriter) { 14 + data["files"] = files 15 + data["meta"] = h.c.Meta 16 + 17 + writeJSON(w, data) 18 + return 19 + } 20 + 21 + func countLines(r io.Reader) (int, error) { 22 + buf := make([]byte, 32*1024) 23 + bufLen := 0 24 + count := 0 25 + nl := []byte{'\n'} 26 + 27 + for { 28 + c, err := r.Read(buf) 29 + if c > 0 { 30 + bufLen += c 31 + } 32 + count += bytes.Count(buf[:c], nl) 33 + 34 + switch { 35 + case err == io.EOF: 36 + /* handle last line not having a newline at the end */ 37 + if bufLen >= 1 && buf[(bufLen-1)%(32*1024)] != '\n' { 38 + count++ 39 + } 40 + return count, nil 41 + case err != nil: 42 + return 0, err 43 + } 44 + } 45 + } 46 + 47 + func (h *Handle) showFile(content string, data map[string]any, w http.ResponseWriter) { 48 + lc, err := countLines(strings.NewReader(content)) 49 + if err != nil { 50 + // Non-fatal, we'll just skip showing line numbers in the template. 51 + log.Printf("counting lines: %s", err) 52 + } 53 + 54 + lines := make([]int, lc) 55 + if lc > 0 { 56 + for i := range lines { 57 + lines[i] = i + 1 58 + } 59 + } 60 + 61 + data["linecount"] = lines 62 + data["content"] = content 63 + data["meta"] = h.c.Meta 64 + 65 + writeJSON(w, data) 66 + return 67 + } 68 + 69 + func (h *Handle) showRaw(content string, w http.ResponseWriter) { 70 + w.WriteHeader(http.StatusOK) 71 + w.Header().Set("Content-Type", "text/plain") 72 + w.Write([]byte(content)) 73 + return 74 + }
+68
knotserver/git.go
··· 1 + package knotserver 2 + 3 + import ( 4 + "compress/gzip" 5 + "io" 6 + "log" 7 + "net/http" 8 + "path/filepath" 9 + 10 + "github.com/icyphox/bild/knotserver/git/service" 11 + ) 12 + func (d *Handle) InfoRefs(w http.ResponseWriter, r *http.Request) { 13 + name := displayRepoName(r) 14 + name = filepath.Clean(name) 15 + 16 + repo := filepath.Join(d.c.Repo.ScanPath, name) 17 + 18 + w.Header().Set("content-type", "application/x-git-upload-pack-advertisement") 19 + w.WriteHeader(http.StatusOK) 20 + 21 + cmd := service.ServiceCommand{ 22 + Dir: repo, 23 + Stdout: w, 24 + } 25 + 26 + if err := cmd.InfoRefs(); err != nil { 27 + http.Error(w, err.Error(), 500) 28 + log.Printf("git: failed to execute git-upload-pack (info/refs) %s", err) 29 + return 30 + } 31 + } 32 + 33 + func (d *Handle) UploadPack(w http.ResponseWriter, r *http.Request) { 34 + name := displayRepoName(r) 35 + name = filepath.Clean(name) 36 + 37 + repo := filepath.Join(d.c.Repo.ScanPath, name) 38 + 39 + w.Header().Set("content-type", "application/x-git-upload-pack-result") 40 + w.Header().Set("Connection", "Keep-Alive") 41 + w.Header().Set("Transfer-Encoding", "chunked") 42 + w.WriteHeader(http.StatusOK) 43 + 44 + cmd := service.ServiceCommand{ 45 + Dir: repo, 46 + Stdout: w, 47 + } 48 + 49 + var reader io.ReadCloser 50 + reader = r.Body 51 + 52 + if r.Header.Get("Content-Encoding") == "gzip" { 53 + reader, err := gzip.NewReader(r.Body) 54 + if err != nil { 55 + http.Error(w, err.Error(), 500) 56 + log.Printf("git: failed to create gzip reader: %s", err) 57 + return 58 + } 59 + defer reader.Close() 60 + } 61 + 62 + cmd.Stdin = reader 63 + if err := cmd.UploadPack(); err != nil { 64 + http.Error(w, err.Error(), 500) 65 + log.Printf("git: failed to execute git-upload-pack %s", err) 66 + return 67 + } 68 + }
+119
knotserver/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 + }
+344
knotserver/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 + }
+33
knotserver/git/repo.go
··· 1 + package git 2 + 3 + import ( 4 + "errors" 5 + "fmt" 6 + "os" 7 + "path/filepath" 8 + 9 + gogit "github.com/go-git/go-git/v5" 10 + "github.com/go-git/go-git/v5/config" 11 + ) 12 + 13 + func InitBare(path string) error { 14 + parent := filepath.Dir(path) 15 + 16 + if err := os.MkdirAll(parent, 0755); errors.Is(err, os.ErrExist) { 17 + return fmt.Errorf("error creating user directory: %w", err) 18 + } 19 + 20 + repository, err := gogit.PlainInit(path, true) 21 + if err != nil { 22 + return err 23 + } 24 + 25 + err = repository.CreateBranch(&config.Branch{ 26 + Name: "main", 27 + }) 28 + if err != nil { 29 + return fmt.Errorf("creating branch: %w", err) 30 + } 31 + 32 + return nil 33 + }
+121
knotserver/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 + }
+25
knotserver/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 + }
+66
knotserver/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 + }
+78
knotserver/handler.go
··· 1 + package knotserver 2 + 3 + import ( 4 + "net/http" 5 + 6 + "github.com/go-chi/chi" 7 + "github.com/icyphox/bild/config" 8 + "github.com/icyphox/bild/db" 9 + ) 10 + 11 + func Setup(c *config.Config, db *db.DB) (http.Handler, error) { 12 + r := chi.NewRouter() 13 + 14 + h := Handle{ 15 + c: c, 16 + db: db, 17 + } 18 + 19 + // r.Route("/repo", func(r chi.Router) { 20 + // r.Use(h.AuthMiddleware) 21 + // r.Get("/new", h.NewRepo) 22 + // r.Put("/new", h.NewRepo) 23 + // }) 24 + 25 + r.Route("/{did}", func(r chi.Router) { 26 + r.Get("/", h.Index) 27 + 28 + // Repo routes 29 + r.Route("/{name}", func(r chi.Router) { 30 + r.Get("/", h.Multiplex) 31 + r.Post("/", h.Multiplex) 32 + 33 + r.Route("/tree/{ref}", func(r chi.Router) { 34 + r.Get("/*", h.RepoTree) 35 + }) 36 + 37 + r.Route("/blob/{ref}", func(r chi.Router) { 38 + r.Get("/*", h.FileContent) 39 + }) 40 + 41 + r.Get("/log/{ref}", h.Log) 42 + r.Get("/archive/{file}", h.Archive) 43 + r.Get("/commit/{ref}", h.Diff) 44 + r.Get("/refs/", h.Refs) 45 + 46 + // Catch-all routes 47 + r.Get("/*", h.Multiplex) 48 + r.Post("/*", h.Multiplex) 49 + }) 50 + }) 51 + 52 + return r, nil 53 + } 54 + 55 + type Handle struct { 56 + c *config.Config 57 + db *db.DB 58 + } 59 + 60 + func (h *Handle) Multiplex(w http.ResponseWriter, r *http.Request) { 61 + path := chi.URLParam(r, "*") 62 + 63 + if r.URL.RawQuery == "service=git-receive-pack" { 64 + w.WriteHeader(http.StatusBadRequest) 65 + w.Write([]byte("no pushing allowed!")) 66 + return 67 + } 68 + 69 + if path == "info/refs" && 70 + r.URL.RawQuery == "service=git-upload-pack" && 71 + r.Method == "GET" { 72 + h.InfoRefs(w, r) 73 + } else if path == "git-upload-pack" && r.Method == "POST" { 74 + h.UploadPack(w, r) 75 + } else if r.Method == "GET" { 76 + h.RepoIndex(w, r) 77 + } 78 + }
+26
knotserver/http_util.go
··· 1 + package knotserver 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + ) 7 + 8 + func writeJSON(w http.ResponseWriter, data interface{}) { 9 + w.Header().Set("Content-Type", "application/json") 10 + w.WriteHeader(http.StatusOK) 11 + json.NewEncoder(w).Encode(data) 12 + } 13 + 14 + func writeError(w http.ResponseWriter, msg string, status int) { 15 + w.Header().Set("Content-Type", "application/json") 16 + w.WriteHeader(status) 17 + json.NewEncoder(w).Encode(map[string]string{"error": msg}) 18 + } 19 + 20 + func notFound(w http.ResponseWriter) { 21 + writeError(w, "not found", http.StatusNotFound) 22 + } 23 + 24 + func writeMsg(w http.ResponseWriter, msg string) { 25 + writeJson(w, map[string]string{"msg": msg}) 26 + }
+415
knotserver/routes.go
··· 1 + package knotserver 2 + 3 + import ( 4 + "compress/gzip" 5 + "errors" 6 + "fmt" 7 + "html/template" 8 + "log" 9 + "net/http" 10 + "path/filepath" 11 + "strconv" 12 + "strings" 13 + 14 + "github.com/go-chi/chi/v5" 15 + "github.com/go-git/go-git/v5/plumbing" 16 + "github.com/icyphox/bild/git" 17 + "github.com/russross/blackfriday/v2" 18 + ) 19 + 20 + func (h *Handle) Index(w http.ResponseWriter, r *http.Request) { 21 + w.Write([]byte("This is a knot, part of the wider Tangle network: https://knots.sh")) 22 + } 23 + 24 + func (h *Handle) RepoIndex(w http.ResponseWriter, r *http.Request) { 25 + path := filepath.Join(h.c.Repo.ScanPath, didPath(r)) 26 + 27 + gr, err := git.Open(path, "") 28 + if err != nil { 29 + if errors.Is(err, plumbing.ErrReferenceNotFound) { 30 + writeMsg(w, "repo empty") 31 + return 32 + } else { 33 + notFound(w) 34 + return 35 + } 36 + } 37 + commits, err := gr.Commits() 38 + if err != nil { 39 + writeError(w, err.Error(), http.StatusInternalServerError) 40 + log.Println(err) 41 + return 42 + } 43 + 44 + var readmeContent template.HTML 45 + for _, readme := range h.c.Repo.Readme { 46 + ext := filepath.Ext(readme) 47 + content, _ := gr.FileContent(readme) 48 + if len(content) > 0 { 49 + switch ext { 50 + case ".md", ".mkd", ".markdown": 51 + unsafe := blackfriday.Run( 52 + []byte(content), 53 + blackfriday.WithExtensions(blackfriday.CommonExtensions), 54 + ) 55 + html := sanitize(unsafe) 56 + readmeContent = template.HTML(html) 57 + default: 58 + safe := sanitize([]byte(content)) 59 + readmeContent = template.HTML( 60 + fmt.Sprintf(`<pre>%s</pre>`, safe), 61 + ) 62 + } 63 + break 64 + } 65 + } 66 + 67 + if readmeContent == "" { 68 + log.Printf("no readme found for %s", path) 69 + } 70 + 71 + mainBranch, err := gr.FindMainBranch(h.c.Repo.MainBranch) 72 + if err != nil { 73 + writeError(w, err.Error(), http.StatusInternalServerError) 74 + log.Println(err) 75 + return 76 + } 77 + 78 + if len(commits) >= 3 { 79 + commits = commits[:3] 80 + } 81 + data := make(map[string]any) 82 + data["ref"] = mainBranch 83 + data["readme"] = readmeContent 84 + data["commits"] = commits 85 + data["desc"] = getDescription(path) 86 + data["servername"] = h.c.Server.Name 87 + data["meta"] = h.c.Meta 88 + 89 + writeJSON(w, data) 90 + return 91 + } 92 + 93 + func (h *Handle) RepoTree(w http.ResponseWriter, r *http.Request) { 94 + treePath := chi.URLParam(r, "*") 95 + ref := chi.URLParam(r, "ref") 96 + 97 + path := filepath.Join(h.c.Repo.ScanPath, didPath(r)) 98 + gr, err := git.Open(path, ref) 99 + if err != nil { 100 + notFound(w) 101 + return 102 + } 103 + 104 + files, err := gr.FileTree(treePath) 105 + if err != nil { 106 + writeError(w, err.Error(), http.StatusInternalServerError) 107 + log.Println(err) 108 + return 109 + } 110 + 111 + data := make(map[string]any) 112 + data["ref"] = ref 113 + data["parent"] = treePath 114 + data["desc"] = getDescription(path) 115 + data["dotdot"] = filepath.Dir(treePath) 116 + 117 + h.listFiles(files, data, w) 118 + return 119 + } 120 + 121 + func (h *Handle) FileContent(w http.ResponseWriter, r *http.Request) { 122 + var raw bool 123 + if rawParam, err := strconv.ParseBool(r.URL.Query().Get("raw")); err == nil { 124 + raw = rawParam 125 + } 126 + 127 + treePath := chi.URLParam(r, "*") 128 + ref := chi.URLParam(r, "ref") 129 + 130 + path := filepath.Join(h.c.Repo.ScanPath, didPath(r)) 131 + gr, err := git.Open(path, ref) 132 + if err != nil { 133 + notFound(w) 134 + return 135 + } 136 + 137 + contents, err := gr.FileContent(treePath) 138 + if err != nil { 139 + writeError(w, err.Error(), http.StatusInternalServerError) 140 + return 141 + } 142 + data := make(map[string]any) 143 + data["ref"] = ref 144 + data["desc"] = getDescription(path) 145 + data["path"] = treePath 146 + 147 + safe := sanitize([]byte(contents)) 148 + 149 + if raw { 150 + h.showRaw(string(safe), w) 151 + } else { 152 + h.showFile(string(safe), data, w) 153 + } 154 + } 155 + 156 + func (h *Handle) Archive(w http.ResponseWriter, r *http.Request) { 157 + name := displayRepoName(r) 158 + 159 + file := chi.URLParam(r, "file") 160 + 161 + // TODO: extend this to add more files compression (e.g.: xz) 162 + if !strings.HasSuffix(file, ".tar.gz") { 163 + notFound(w) 164 + return 165 + } 166 + 167 + ref := strings.TrimSuffix(file, ".tar.gz") 168 + 169 + // This allows the browser to use a proper name for the file when 170 + // downloading 171 + filename := fmt.Sprintf("%s-%s.tar.gz", name, ref) 172 + setContentDisposition(w, filename) 173 + setGZipMIME(w) 174 + 175 + path := filepath.Join(h.c.Repo.ScanPath, didPath(r)) 176 + gr, err := git.Open(path, ref) 177 + if err != nil { 178 + notFound(w) 179 + return 180 + } 181 + 182 + gw := gzip.NewWriter(w) 183 + defer gw.Close() 184 + 185 + prefix := fmt.Sprintf("%s-%s", name, ref) 186 + err = gr.WriteTar(gw, prefix) 187 + if err != nil { 188 + // once we start writing to the body we can't report error anymore 189 + // so we are only left with printing the error. 190 + log.Println(err) 191 + return 192 + } 193 + 194 + err = gw.Flush() 195 + if err != nil { 196 + // once we start writing to the body we can't report error anymore 197 + // so we are only left with printing the error. 198 + log.Println(err) 199 + return 200 + } 201 + } 202 + 203 + func (h *Handle) Log(w http.ResponseWriter, r *http.Request) { 204 + ref := chi.URLParam(r, "ref") 205 + 206 + path := filepath.Join(h.c.Repo.ScanPath, didPath(r)) 207 + gr, err := git.Open(path, ref) 208 + if err != nil { 209 + notFound(w) 210 + return 211 + } 212 + 213 + commits, err := gr.Commits() 214 + if err != nil { 215 + writeError(w, err.Error(), http.StatusInternalServerError) 216 + log.Println(err) 217 + return 218 + } 219 + 220 + data := make(map[string]interface{}) 221 + data["commits"] = commits 222 + data["meta"] = h.c.Meta 223 + data["ref"] = ref 224 + data["desc"] = getDescription(path) 225 + data["log"] = true 226 + 227 + writeJSON(w, data) 228 + return 229 + } 230 + 231 + func (h *Handle) Diff(w http.ResponseWriter, r *http.Request) { 232 + name := displayRepoName(r) 233 + if h.isIgnored(name) { 234 + notFound(w) 235 + return 236 + } 237 + ref := chi.URLParam(r, "ref") 238 + 239 + path := filepath.Join(h.c.Repo.ScanPath, didPath(r)) 240 + gr, err := git.Open(path, ref) 241 + if err != nil { 242 + notFound(w) 243 + return 244 + } 245 + 246 + diff, err := gr.Diff() 247 + if err != nil { 248 + writeError(w, err.Error(), http.StatusInternalServerError) 249 + log.Println(err) 250 + return 251 + } 252 + 253 + data := make(map[string]interface{}) 254 + 255 + data["commit"] = diff.Commit 256 + data["stat"] = diff.Stat 257 + data["diff"] = diff.Diff 258 + data["meta"] = h.c.Meta 259 + data["ref"] = ref 260 + data["desc"] = getDescription(path) 261 + 262 + writeJSON(w, data) 263 + return 264 + } 265 + 266 + func (h *Handle) Refs(w http.ResponseWriter, r *http.Request) { 267 + path := filepath.Join(h.c.Repo.ScanPath, didPath(r)) 268 + gr, err := git.Open(path, "") 269 + if err != nil { 270 + notFound(w) 271 + return 272 + } 273 + 274 + tags, err := gr.Tags() 275 + if err != nil { 276 + // Non-fatal, we *should* have at least one branch to show. 277 + log.Println(err) 278 + } 279 + 280 + branches, err := gr.Branches() 281 + if err != nil { 282 + log.Println(err) 283 + writeError(w, err.Error(), http.StatusInternalServerError) 284 + return 285 + } 286 + 287 + data := make(map[string]interface{}) 288 + 289 + data["meta"] = h.c.Meta 290 + data["branches"] = branches 291 + data["tags"] = tags 292 + data["desc"] = getDescription(path) 293 + 294 + writeJSON(w, data) 295 + return 296 + } 297 + 298 + func (h *Handle) ServeStatic(w http.ResponseWriter, r *http.Request) { 299 + f := chi.URLParam(r, "file") 300 + f = filepath.Clean(filepath.Join(h.c.Dirs.Static, f)) 301 + 302 + http.ServeFile(w, r, f) 303 + } 304 + 305 + // func (h *Handle) Keys(w http.ResponseWriter, r *http.Request) { 306 + // session, _ := h.s.Get(r, "bild-session") 307 + // did := session.Values["did"].(string) 308 + 309 + // switch r.Method { 310 + // case http.MethodGet: 311 + // keys, err := h.db.GetPublicKeys(did) 312 + // if err != nil { 313 + // h.WriteOOBNotice(w, "keys", "Failed to list keys. Try again later.") 314 + // log.Println(err) 315 + // return 316 + // } 317 + 318 + // data := make(map[string]interface{}) 319 + // data["keys"] = keys 320 + // if err := h.t.ExecuteTemplate(w, "settings/keys", data); err != nil { 321 + // log.Println(err) 322 + // return 323 + // } 324 + // case http.MethodPut: 325 + // key := r.FormValue("key") 326 + // name := r.FormValue("name") 327 + // client, _ := h.auth.AuthorizedClient(r) 328 + 329 + // _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key)) 330 + // if err != nil { 331 + // h.WriteOOBNotice(w, "keys", "Invalid public key. Check your formatting and try again.") 332 + // log.Printf("parsing public key: %s", err) 333 + // return 334 + // } 335 + 336 + // if err := h.db.AddPublicKey(did, name, key); err != nil { 337 + // h.WriteOOBNotice(w, "keys", "Failed to add key.") 338 + // log.Printf("adding public key: %s", err) 339 + // return 340 + // } 341 + 342 + // // store in pds too 343 + // resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 344 + // Collection: "sh.bild.publicKey", 345 + // Repo: did, 346 + // Rkey: uuid.New().String(), 347 + // Record: &lexutil.LexiconTypeDecoder{Val: &shbild.PublicKey{ 348 + // Created: time.Now().String(), 349 + // Key: key, 350 + // Name: name, 351 + // }}, 352 + // }) 353 + 354 + // // invalid record 355 + // if err != nil { 356 + // h.WriteOOBNotice(w, "keys", "Invalid inputs. Check your formatting and try again.") 357 + // log.Printf("failed to create record: %s", err) 358 + // return 359 + // } 360 + 361 + // log.Println("created atproto record: ", resp.Uri) 362 + 363 + // h.WriteOOBNotice(w, "keys", "Key added!") 364 + // return 365 + // } 366 + // } 367 + 368 + // func (h *Handle) NewRepo(w http.ResponseWriter, r *http.Request) { 369 + // session, _ := h.s.Get(r, "bild-session") 370 + // did := session.Values["did"].(string) 371 + // handle := session.Values["handle"].(string) 372 + 373 + // switch r.Method { 374 + // case http.MethodGet: 375 + // if err := h.t.ExecuteTemplate(w, "repo/new", nil); err != nil { 376 + // log.Println(err) 377 + // return 378 + // } 379 + // case http.MethodPut: 380 + // name := r.FormValue("name") 381 + // description := r.FormValue("description") 382 + 383 + // repoPath := filepath.Join(h.c.Repo.ScanPath, did, name) 384 + // err := git.InitBare(repoPath) 385 + // if err != nil { 386 + // h.WriteOOBNotice(w, "repo", "Error creating repo. Try again later.") 387 + // return 388 + // } 389 + 390 + // err = h.db.AddRepo(did, name, description) 391 + // if err != nil { 392 + // h.WriteOOBNotice(w, "repo", "Error creating repo. Try again later.") 393 + // return 394 + // } 395 + 396 + // w.Header().Set("HX-Redirect", fmt.Sprintf("/@%s/%s", handle, name)) 397 + // w.WriteHeader(http.StatusOK) 398 + // } 399 + // } 400 + 401 + // func (h *Handle) Timeline(w http.ResponseWriter, r *http.Request) { 402 + // session, err := h.s.Get(r, "bild-session") 403 + // user := make(map[string]string) 404 + // if err != nil || session.IsNew { 405 + // // user is not logged in 406 + // } else { 407 + // user["handle"] = session.Values["handle"].(string) 408 + // user["did"] = session.Values["did"].(string) 409 + // } 410 + 411 + // if err := h.t.ExecuteTemplate(w, "timeline", user); err != nil { 412 + // log.Println(err) 413 + // return 414 + // } 415 + // }
+136
knotserver/util.go
··· 1 + package knotserver 2 + 3 + import ( 4 + "fmt" 5 + "io/fs" 6 + "log" 7 + "net/http" 8 + "os" 9 + "path/filepath" 10 + "strings" 11 + 12 + "github.com/go-chi/chi/v5" 13 + "github.com/icyphox/bild/auth" 14 + "github.com/icyphox/bild/git" 15 + "github.com/microcosm-cc/bluemonday" 16 + ) 17 + 18 + func sanitize(content []byte) []byte { 19 + return bluemonday.UGCPolicy().SanitizeBytes([]byte(content)) 20 + } 21 + 22 + func displayRepoName(r *http.Request) string { 23 + user := r.Context().Value("did").(string) 24 + name := chi.URLParam(r, "name") 25 + 26 + handle, err := auth.ResolveIdent(r.Context(), user) 27 + if err != nil { 28 + log.Printf("failed to resolve ident: %s: %s", user, err) 29 + return fmt.Sprintf("%s/%s", user, name) 30 + } 31 + 32 + return fmt.Sprintf("@%s/%s", handle.Handle.String(), name) 33 + } 34 + 35 + func didPath(r *http.Request, did string) string { 36 + path := filepath.Join(did, chi.URLParam(r, "name")) 37 + filepath.Clean(path) 38 + return path 39 + } 40 + 41 + func getDescription(path string) (desc string) { 42 + db, err := os.ReadFile(filepath.Join(path, "description")) 43 + if err == nil { 44 + desc = string(db) 45 + } else { 46 + desc = "" 47 + } 48 + return 49 + } 50 + 51 + func (h *Handle) isUnlisted(name string) bool { 52 + for _, i := range h.c.Repo.Unlisted { 53 + if name == i { 54 + return true 55 + } 56 + } 57 + 58 + return false 59 + } 60 + 61 + func (h *Handle) isIgnored(name string) bool { 62 + for _, i := range h.c.Repo.Ignore { 63 + if name == i { 64 + return true 65 + } 66 + } 67 + 68 + return false 69 + } 70 + 71 + type repoInfo struct { 72 + Git *git.GitRepo 73 + Path string 74 + Category string 75 + } 76 + 77 + func (d *Handle) getAllRepos() ([]repoInfo, error) { 78 + repos := []repoInfo{} 79 + max := strings.Count(d.c.Repo.ScanPath, string(os.PathSeparator)) + 2 80 + 81 + err := filepath.WalkDir(d.c.Repo.ScanPath, func(path string, de fs.DirEntry, err error) error { 82 + if err != nil { 83 + return err 84 + } 85 + 86 + if de.IsDir() { 87 + // Check if we've exceeded our recursion depth 88 + if strings.Count(path, string(os.PathSeparator)) > max { 89 + return fs.SkipDir 90 + } 91 + 92 + if d.isIgnored(path) { 93 + return fs.SkipDir 94 + } 95 + 96 + // A bare repo should always have at least a HEAD file, if it 97 + // doesn't we can continue recursing 98 + if _, err := os.Lstat(filepath.Join(path, "HEAD")); err == nil { 99 + repo, err := git.Open(path, "") 100 + if err != nil { 101 + log.Println(err) 102 + } else { 103 + relpath, _ := filepath.Rel(d.c.Repo.ScanPath, path) 104 + repos = append(repos, repoInfo{ 105 + Git: repo, 106 + Path: relpath, 107 + Category: d.category(path), 108 + }) 109 + // Since we found a Git repo, we don't want to recurse 110 + // further 111 + return fs.SkipDir 112 + } 113 + } 114 + } 115 + return nil 116 + }) 117 + 118 + return repos, err 119 + } 120 + 121 + func (d *Handle) category(path string) string { 122 + return strings.TrimPrefix(filepath.Dir(strings.TrimPrefix(path, d.c.Repo.ScanPath)), string(os.PathSeparator)) 123 + } 124 + 125 + func setContentDisposition(w http.ResponseWriter, name string) { 126 + h := "inline; filename=\"" + name + "\"" 127 + w.Header().Add("Content-Disposition", h) 128 + } 129 + 130 + func setGZipMIME(w http.ResponseWriter) { 131 + setMIME(w, "application/gzip") 132 + } 133 + 134 + func setMIME(w http.ResponseWriter, mime string) { 135 + w.Header().Add("Content-Type", mime) 136 + }