package forge import ( "encoding/base64" "errors" "html/template" "io" "mime" "net/http" "os" "path/filepath" "regexp" "slices" "strconv" "strings" "unicode/utf8" ) var fullOID = regexp.MustCompile(`^[0-9a-f]{40}$`) func (s *Server) gistIndex(w http.ResponseWriter, r *http.Request) { user := s.current(r) gists, err := s.store.Gists(0, false, 50) if err != nil { s.fail(w, r, 500, err.Error()) return } s.render(w, r, "gists", page{Title: "Gists", User: user, Data: map[string]any{"Items": s.gistItems(gists)}}) } func (s *Server) userGists(w http.ResponseWriter, r *http.Request, owner User, user *User) { own := user != nil && user.ID == owner.ID gists, err := s.store.Gists(owner.ID, own, 200) if err != nil { s.fail(w, r, 500, err.Error()) return } data := map[string]any{"Owner": owner, "Items": s.gistItems(gists), "Own": own, "Count": len(gists)} if r.URL.Query().Get("tab") == "starred" { starred, _ := s.store.StarredGists(owner.ID) data["Items"], data["Starred"] = s.gistItems(starred), true } s.render(w, r, "gists", page{Title: "~" + owner.Name + " gists", User: user, Data: data}) } func formFiles(r *http.Request) []gistFile { names, contents, origs := r.PostForm["name"], r.PostForm["content"], r.PostForm["orig"] var files []gistFile for i := range min(len(contents), gistFormFiles) { f := gistFile{Content: strings.ReplaceAll(contents[i], "\r\n", "\n")} if i < len(names) { f.Name = strings.TrimSpace(names[i]) } if i < len(origs) { f.Orig = origs[i] } files = append(files, f) } return files } func (s *Server) newGistForm(w http.ResponseWriter, r *http.Request) { u := s.require(w, r) if u == nil { return } s.render(w, r, "gistform", page{Title: "New gist", User: u, Data: map[string]any{"Files": []gistFile{{}}, "Action": "/gists"}}) } func (s *Server) postGist(w http.ResponseWriter, r *http.Request) { u := s.require(w, r) if u == nil { return } r.Body = http.MaxBytesReader(w, r.Body, 8<<20) if err := r.ParseForm(); err != nil { s.fail(w, r, 400, "The gist is too large") return } files := formFiles(r) data := map[string]any{"Files": files, "Action": "/gists", "Description": r.FormValue("description"), "Secret": r.FormValue("visibility") == "secret"} if r.FormValue("action") == "add" { data["Files"] = append(files, gistFile{}) s.render(w, r, "gistform", page{Title: "New gist", User: u, Data: data}) return } gist, err := s.createGist(*u, r.FormValue("description"), r.FormValue("visibility") == "secret", files) if err != nil { w.WriteHeader(400) s.render(w, r, "gistform", page{Title: "New gist", User: u, Error: err.Error(), Data: data}) return } http.Redirect(w, r, "/gist/"+gist.ID, http.StatusSeeOther) } func (s *Server) gistRoute(w http.ResponseWriter, r *http.Request) { id, sub, _ := strings.Cut(strings.TrimPrefix(r.URL.Path, "/gist/"), "/") id = strings.TrimSuffix(id, ".git") if !gistID.MatchString(id) { s.fail(w, r, 404, "No such gist") return } gist, err := s.store.Gist(id) if err != nil { if isSmart(r, sub) { http.Error(w, "gist not found", 404) return } s.fail(w, r, 404, "No such gist") return } if isSmart(r, sub) { s.gistSmart(w, r, gist, sub) return } user := s.current(r) back := "/gist/" + gist.ID switch { case sub == "" && r.Method == "GET": s.gistPage(w, r, gist, user) case sub == "revisions" && r.Method == "GET": s.gistRevisions(w, r, gist, user) case sub == "edit": s.editGist(w, r, gist, user) case strings.HasPrefix(sub, "raw/") && r.Method == "GET": http.Redirect(w, r, "https://"+s.rawHost()+"/raw/"+gist.ID+"/"+escapePath(strings.TrimPrefix(sub, "raw/")), http.StatusFound) case r.Method != "POST": s.fail(w, r, 404, "Page not found") case user == nil: http.Redirect(w, r, "/login", http.StatusSeeOther) case sub == "star": s.store.StarGist(user.ID, gist.ID, !s.store.GistStarred(user.ID, gist.ID)) http.Redirect(w, r, back, http.StatusSeeOther) case sub == "fork": fork, err := s.forkGistFor(*user, gist) if err != nil { s.fail(w, r, problemCode(err), err.Error()) return } http.Redirect(w, r, "/gist/"+fork.ID, http.StatusSeeOther) case sub == "delete": if err := s.removeGist(*user, gist); err != nil { s.fail(w, r, problemCode(err), err.Error()) return } if user.ID != gist.Owner.ID { http.Redirect(w, r, "/admin?s=gists", http.StatusSeeOther) return } http.Redirect(w, r, "/~"+gist.Owner.Name+"/gists", http.StatusSeeOther) case sub == "comment": s.settle(w, r, back+"#comments", s.commentGist(*user, gist, r.FormValue("body"))) case strings.HasPrefix(sub, "comment/") && strings.HasSuffix(sub, "/delete"): cid, _ := strconv.ParseInt(strings.TrimSuffix(strings.TrimPrefix(sub, "comment/"), "/delete"), 10, 64) s.settle(w, r, back+"#comments", s.dropGistComment(*user, gist, cid)) default: s.fail(w, r, 404, "Page not found") } } func (s *Server) gistPage(w http.ResponseWriter, r *http.Request, gist Gist, user *User) { head := s.gistHead(gist) commit := head if rev := r.URL.Query().Get("rev"); fullOID.MatchString(rev) { if c, err := s.gistGit(gist).Resolve(rev + "^{commit}"); err == nil { commit = c } } views := s.gistViews(gist, commit, 0) var files []string for _, v := range views { files = append(files, v.Name) } comments := s.store.GistComments(gist.ID) rendered := make([]map[string]any, len(comments)) math := false for i, c := range comments { body := s.renderMarkdown([]byte(c.Body)) math = math || hasMath(body) rendered[i] = map[string]any{"Comment": c, "Body": body} } for _, v := range views { math = math || hasMath(v.Markdown) } data := map[string]any{ "Gist": gist, "Files": views, "Title": gist.Title(files), "Commit": commit, "Head": head, "Old": commit != head, "Comments": rendered, "Owner": user != nil && user.ID == gist.Owner.ID, "Admin": user != nil && s.store.IsAdmin(user.ID), "Starred": user != nil && s.store.GistStarred(user.ID, gist.ID), "Clone": "https://" + s.host + "/gist/" + gist.ID + ".git", "SSH": s.sshUser + "@" + s.sshHost + ":gist/" + gist.ID + ".git", "Embed": ``, } if c, err := s.gistGit(gist).Commit(commit); err == nil { data["Last"] = c } if gist.Parent != "" { if p, err := s.store.Gist(gist.Parent); err == nil && !p.Secret { data["Parent"] = p } } if forks, _ := s.store.GistForks(gist.ID); len(forks) > 0 { data["Forks"] = forks } s.render(w, r, "gist", page{Title: gist.Title(files), User: user, Error: r.URL.Query().Get("error"), Math: math, Data: data}) } func (s *Server) gistRevisions(w http.ResponseWriter, r *http.Request, gist Gist, user *User) { g := s.gistGit(gist) from, _ := strconv.Atoi(r.URL.Query().Get("from")) from = max(from, 0) commits, _ := g.Log(s.gistHead(gist), gistRevisionPage+1, "", "--skip="+strconv.Itoa(from)) type revision struct { Commit Commit Files []File } data := map[string]any{"Gist": gist, "Total": s.gistRevisionCount(gist)} if len(commits) > gistRevisionPage { commits = commits[:gistRevisionPage] data["Next"] = from + gistRevisionPage } if from > 0 { data["Prev"] = max(0, from-gistRevisionPage) data["Paged"] = true } revs := make([]revision, 0, len(commits)) for _, c := range commits { files, _ := g.Diff(c.Hash) revs = append(revs, revision{Commit: c, Files: files}) } data["Revisions"] = revs s.render(w, r, "gistlog", page{Title: "Revisions of gist " + gist.ID[:8], User: user, Data: data}) } const gistRevisionPage = 30 func (s *Server) gistRevisionCount(gist Gist) int { out, err := s.gistGit(gist).run(nil, "rev-list", "--count", "refs/heads/main") if err != nil { return 0 } n, _ := strconv.Atoi(strings.TrimSpace(string(out))) return n } func (s *Server) editGist(w http.ResponseWriter, r *http.Request, gist Gist, user *User) { if user == nil { http.Redirect(w, r, "/login", http.StatusSeeOther) return } if user.ID != gist.Owner.ID { s.fail(w, r, 403, "Only the owner can edit this gist") return } g := s.gistGit(gist) head := s.gistHead(gist) if r.Method == "POST" { r.Body = http.MaxBytesReader(w, r.Body, 8<<20) if err := r.ParseForm(); err != nil { s.fail(w, r, 400, "The gist is too large") return } head = r.FormValue("head") } entries, err := g.gistTree(head) if err != nil { s.fail(w, r, 409, "The gist changed while you were editing it, reload and try again") return } var files []gistFile var keep []gistEntry for _, e := range entries { if e.Size <= gistWebFile { if content, err := g.run(nil, "cat-file", "blob", e.OID); err == nil && !isBinary(content) { files = append(files, gistFile{Orig: e.Name, Name: e.Name, Content: string(content)}) continue } } keep = append(keep, e) } data := map[string]any{"Gist": gist, "Files": files, "Keep": keep, "Head": head, "Action": "/gist/" + gist.ID + "/edit", "Description": gist.Description, "Secret": gist.Secret} if r.Method != "POST" { s.render(w, r, "gistform", page{Title: "Edit gist", User: user, Data: data}) return } posted := formFiles(r) secret := r.FormValue("visibility") == "secret" data["Files"], data["Description"], data["Secret"] = posted, r.FormValue("description"), secret if r.FormValue("action") == "add" { data["Files"] = append(posted, gistFile{}) s.render(w, r, "gistform", page{Title: "Edit gist", User: user, Data: data}) return } retry := func(msg string) { w.WriteHeader(400) s.render(w, r, "gistform", page{Title: "Edit gist", User: user, Error: msg, Data: data}) } description, err := cleanDescription(r.FormValue("description")) if err != nil { retry(err.Error()) return } if over, used, quota := overQuota(s.root, user.Name); over { retry(quotaMessage(used, quota)) return } if _, err := s.writeGist(g, head, posted, keep, *user, "Edit gist"); err != nil && !errors.Is(err, errNoChange) { retry(err.Error()) return } s.store.UpdateGist(gist.ID, description, secret) http.Redirect(w, r, "/gist/"+gist.ID, http.StatusSeeOther) } func (s *Server) gistSmart(w http.ResponseWriter, r *http.Request, gist Gist, rest string) { service := r.URL.Query().Get("service") if rest != "info/refs" { service = rest } if service != "git-upload-pack" && service != "git-receive-pack" { http.Error(w, "unsupported service", 403) return } var recv, env []string limit := int64(0) if service == "git-receive-pack" { ip := clientIP(r) if s.limiter.blocked(ip) { http.Error(w, "too many failed attempts", 429) return } u, scope, ok := s.basicUser(r) if ok && scope == scopeRead { http.Error(w, "this token is read only", 403) return } if !ok || u.ID != gist.Owner.ID { if _, _, given := r.BasicAuth(); given { s.limiter.fail(ip) } w.Header().Set("WWW-Authenticate", `Basic realm="`+s.host+`"`) http.Error(w, "authentication required", 401) return } if !hooksReady(s.root) { http.Error(w, "pushes are unavailable right now", 503) return } over, used, quota := overQuota(s.root, gist.Owner.Name) if over { http.Error(w, quotaMessage(used, quota), 403) return } if quota > 0 { limit = quota - used + 1<<20 } recv, env = gistReceiveArgs(s.root, gist.Owner.Name), gistEnv(gist, u) } s.gitService(w, r, s.gistGit(gist).Dir, service, rest, recv, env, limit) if service == "git-receive-pack" && rest != "info/refs" { s.store.TouchGist(gist.ID) } } const rawPolicy = "default-src 'none'; img-src data:; style-src 'unsafe-inline'; sandbox" const embedPolicy = "default-src 'none'; style-src 'unsafe-inline'; img-src https: data:; base-uri 'none'; form-action 'none'; frame-ancestors *" func (s *Server) serveGistRaw(w http.ResponseWriter, r *http.Request) { h := w.Header() h.Set("X-Content-Type-Options", "nosniff") h.Set("Referrer-Policy", "no-referrer") if r.Method != "GET" && r.Method != "HEAD" { h.Set("Allow", "GET, HEAD") http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } if !s.requests.allow(clientIP(r)) { h.Set("Retry-After", "10") http.Error(w, "too many requests, slow down", http.StatusTooManyRequests) return } notFound := func() { http.Error(w, "not found", http.StatusNotFound) } kind, rest, _ := strings.Cut(strings.TrimPrefix(r.URL.Path, "/"), "/") id, rest, _ := strings.Cut(rest, "/") if kind == "" { http.Redirect(w, r, "https://"+s.host+"/gists", http.StatusFound) return } if !gistID.MatchString(id) { notFound() return } gist, err := s.store.Gist(id) if err != nil { notFound() return } g := s.gistGit(gist) switch kind { case "embed": if rest != "" { notFound() return } s.gistEmbed(w, r, gist) case "raw": commit, file := s.gistHead(gist), rest pinned := false if rev, name, ok := strings.Cut(rest, "/"); ok && fullOID.MatchString(rev) { c, err := g.Resolve(rev + "^{commit}") if err != nil { notFound() return } commit, file, pinned = c, name, true } if commit == "" || checkGistName(file) != nil { notFound() return } mode, oid, ok := g.pageEntry(commit, file) if !ok || (mode != "100644" && mode != "100755") { notFound() return } data, err := g.run(nil, "cat-file", "blob", oid) if err != nil || len(data) > gistPushFile { notFound() return } ctype := "text/plain; charset=utf-8" if kind := mediaTypes[strings.ToLower(filepath.Ext(file))]; strings.HasPrefix(kind, "image/") { ctype = kind } h.Set("Content-Type", ctype) h.Set("Content-Security-Policy", rawPolicy) h.Set("Access-Control-Allow-Origin", "*") h.Set("Cross-Origin-Resource-Policy", "cross-origin") h.Set("Content-Disposition", mime.FormatMediaType("inline", map[string]string{"filename": file})) if pinned { h.Set("Cache-Control", "public, max-age=31536000, immutable") h.Set("Cloudflare-CDN-Cache-Control", "max-age=3600") } else { h.Set("Cache-Control", "public, max-age=60") } h.Set("Content-Length", strconv.Itoa(len(data))) w.Write(data) default: notFound() } } var embedTemplate = template.Must(template.New("embed").Funcs(template.FuncMap{ "lines": func(b []byte) []string { return strings.Split(strings.TrimSuffix(string(b), "\n"), "\n") }, "inc": func(i int) int { return i + 1 }, }).Parse(`
Binary file.
{{else if .Large}}File too large to display.
{{else if .Markdown}}| {{inc $i}} | {{$l}} |