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(` {{.Title}} {{range .Files}}
{{.Name}}raw ยท hosted on {{$.Host}}
{{if .Image}}{{.Name}}{{else if .Binary}}

Binary file.

{{else if .Large}}

File too large to display.

{{else if .Markdown}}
{{.Markdown}}
{{else if .Highlighted}}{{.Highlighted}}{{else}}{{range $i, $l := lines .Content}}{{end}}
{{inc $i}}{{$l}}
{{end}}
{{end}} `)) func (s *Server) gistEmbed(w http.ResponseWriter, r *http.Request, gist Gist) { head := s.gistHead(gist) if head == "" { http.Error(w, "not found", http.StatusNotFound) return } views := s.gistViews(gist, head, 0) var names []string for _, v := range views { names = append(names, v.Name) } h := w.Header() h.Set("Content-Type", "text/html; charset=utf-8") h.Set("Content-Security-Policy", embedPolicy) h.Set("Cache-Control", "public, max-age=60") embedTemplate.Execute(w, map[string]any{ "Title": gist.Title(names), "Files": views, "Host": s.host, "Link": "https://" + s.host + "/gist/" + gist.ID, "CSS": template.CSS(highlightCSS()), }) } type gistFileDoc struct { Name string `json:"name"` Size int64 `json:"size"` Raw string `json:"raw"` Encoding string `json:"encoding,omitempty"` Content string `json:"content,omitempty"` Truncated bool `json:"truncated,omitempty"` } type gistDoc struct { ID string `json:"id"` Description string `json:"description"` Visibility string `json:"visibility"` Owner string `json:"owner"` Head string `json:"head"` Files []gistFileDoc `json:"files"` Stars int `json:"stars"` Forks int `json:"forks"` Comments int `json:"comments"` ForkOf *string `json:"fork_of"` Created *string `json:"created"` Updated *string `json:"updated"` Clone cloneSet `json:"clone"` Embed string `json:"embed"` URL string `json:"url"` API string `json:"api"` } func (s *Server) gistDoc(gist Gist, content bool) gistDoc { g := s.gistGit(gist) head := s.gistHead(gist) vis := "public" if gist.Secret { vis = "secret" } out := gistDoc{ID: gist.ID, Description: gist.Description, Visibility: vis, Owner: handle(gist.Owner.Name), Head: head, Files: []gistFileDoc{}, Stars: gist.Stars, Forks: gist.Forks, Comments: gist.Comments, Created: iso(gist.Created), Updated: iso(gist.Updated), Clone: cloneSet{HTTPS: "https://" + s.host + "/gist/" + gist.ID + ".git", SSH: s.sshUser + "@" + s.sshHost + ":gist/" + gist.ID + ".git"}, Embed: "https://" + s.rawHost() + "/embed/" + gist.ID, URL: "https://" + s.host + "/gist/" + gist.ID, API: "https://" + s.host + "/api/gists/" + gist.ID} if gist.Parent != "" { if p, err := s.store.Gist(gist.Parent); err == nil && !p.Secret { out.ForkOf = &p.ID } } entries, _ := g.gistTree(head) for _, e := range entries { f := gistFileDoc{Name: e.Name, Size: e.Size, Raw: s.rawURL(gist, head, e.Name)} if content { if e.Size > gistWebFile { f.Truncated = true } else if data, err := g.run(nil, "cat-file", "blob", e.OID); err == nil { if isBinary(data) || !utf8.Valid(data) { f.Encoding, f.Content = "base64", base64.StdEncoding.EncodeToString(data) } else { f.Encoding, f.Content = "utf-8", string(data) } } } out.Files = append(out.Files, f) } return out } func (s *Server) removeGist(u User, gist Gist) error { if u.ID != gist.Owner.ID && !s.store.IsAdmin(u.ID) { return refuse(403, "Only the owner can delete this gist") } s.store.DeleteGist(gist.ID) os.RemoveAll(s.gistGit(gist).Dir) s.store.CloseReports("gist", gist.Owner.Name+"/"+gist.ID, "resolved", u.ID) return nil } func (s *Server) commentGist(u User, gist Gist, body string) error { if s.store.Blocked(gist.Owner.ID, u.ID) { return refuse(403, "You cannot comment here") } return s.store.AddGistComment(gist.ID, u.ID, body) } func (s *Server) dropGistComment(u User, gist Gist, cid int64) error { author, ok := s.store.GistCommentAuthor(gist.ID, cid) if !ok { return refuse(404, "No such comment") } if author != u.ID && gist.Owner.ID != u.ID && !s.store.IsAdmin(u.ID) { return refuse(403, "You cannot delete that comment") } s.store.DeleteGistComment(cid) return nil } func (s *Server) forkGistFor(u User, gist Gist) (Gist, error) { if u.ID == gist.Owner.ID { return Gist{}, refuse(409, "You already own this gist") } return s.forkGist(u, gist) } type gistChange struct { Name string `json:"name"` Content *string `json:"content"` Rename string `json:"rename"` Delete bool `json:"delete"` } func (s *Server) changeGist(u User, gist Gist, head string, changes []gistChange) error { g := s.gistGit(gist) current := s.gistHead(gist) if head == "" { head = current } if head != current { return refuse(409, "The gist moved on to "+short(current)+", fetch it again") } keep, err := g.gistTree(head) if err != nil { return refuse(409, "The gist has no files yet") } var files []gistFile for _, ch := range changes { i := slices.IndexFunc(keep, func(e gistEntry) bool { return e.Name == ch.Name }) if i < 0 { if ch.Delete || ch.Content == nil { return refuse(404, "No file called "+ch.Name) } files = append(files, gistFile{Name: ch.Name, Content: *ch.Content}) continue } e := keep[i] keep = slices.Delete(keep, i, i+1) if ch.Delete { continue } name := ch.Name if ch.Rename != "" { name = ch.Rename } if ch.Content == nil { if err := checkGistName(name); err != nil { return err } e.Name = name keep = append(keep, e) continue } files = append(files, gistFile{Orig: ch.Name, Name: name, Content: *ch.Content}) } _, err = s.writeGist(g, head, files, keep, u, "Edit gist") if errors.Is(err, errNoChange) { return nil } return err } func (s *Server) gistRoutes() []apiRoute { return []apiRoute{ {"GET", "/gists", needNone, "public gists, newest first", s.apiGists, nil}, {"POST", "/gists", needGist, "create from {description, visibility, files: [{name, content}]}, or send the raw file with ?name=&description=&visibility= and get its address back as text", s.apiCreateGist, nil}, {"GET", "/gists/:id", needNone, "a gist with every file's content, secret gists only need their id", s.apiGist, nil}, {"PATCH", "/gists/:id", needGist, "change description or visibility, and files with [{name, content, rename, delete}], head guards against lost updates", s.apiPatchGist, nil}, {"DELETE", "/gists/:id", needGist, "delete the gist", s.apiDeleteGist, nil}, {"PUT", "/gists/:id/files/*name", needGist, "write one file from the raw request body, creating it when missing", s.apiPutGistFile, nil}, {"DELETE", "/gists/:id/files/*name", needGist, "remove one file", s.apiPutGistFile, nil}, {"GET", "/gists/:id/revisions", needNone, "revisions, newest first, ?patch=1 adds the hunks", s.apiGistRevisions, nil}, {"PUT", "/gists/:id/star", needGist, "star", s.apiStarGist, nil}, {"DELETE", "/gists/:id/star", needGist, "unstar", s.apiStarGist, nil}, {"GET", "/gists/:id/forks", needNone, "forks", s.apiGistForks, nil}, {"POST", "/gists/:id/forks", needGist, "fork into your account", s.apiForkGist, nil}, {"GET", "/gists/:id/comments", needNone, "comments, oldest first", s.apiGistComments, nil}, {"POST", "/gists/:id/comments", needGist, "comment with {body}", s.apiCommentGist, nil}, {"DELETE", "/gists/:id/comments/:cid", needGist, "delete a comment, yours or one on your gist", s.apiDropGistComment, nil}, {"GET", "/~:owner/gists", needNone, "an account's public gists, its secret ones too when it is you, ?starred=1 for the gists it starred", s.apiAccountGists, nil}, } } func (c *call) gist() (Gist, bool) { id := c.arg("id") if !gistID.MatchString(id) { c.fail(404, "No such gist") return Gist{}, false } gist, err := c.s.store.Gist(id) if err != nil { c.fail(404, "No such gist") return Gist{}, false } return gist, true } func (s *Server) gistDocs(gists []Gist) []gistDoc { return mapped(gists, func(g Gist) gistDoc { return s.gistDoc(g, false) }) } func (s *Server) apiGists(c *call) { gists, err := s.store.Gists(0, false, 1000) if err != nil { c.err(err) return } paged(c, s.gistDocs(gists)) } func (s *Server) apiAccountGists(c *call) { owner, ok := c.account() if !ok { return } var gists []Gist var err error if c.query("starred") == "1" { gists, err = s.store.StarredGists(owner.ID) gists = slices.DeleteFunc(gists, func(g Gist) bool { return g.Secret && (c.user == nil || c.user.ID != g.Owner.ID) }) } else { gists, err = s.store.Gists(owner.ID, c.user != nil && c.user.ID == owner.ID, 1000) } if err != nil { c.err(err) return } paged(c, s.gistDocs(gists)) } func (s *Server) apiCreateGist(c *call) { var description, vis string var files []gistFile if strings.HasPrefix(c.r.Header.Get("Content-Type"), "application/json") { var in struct { Description string `json:"description"` Visibility string `json:"visibility"` Files []struct { Name string `json:"name"` Content string `json:"content"` } `json:"files"` } if !c.decode(&in) { return } description, vis = in.Description, in.Visibility for _, f := range in.Files { files = append(files, gistFile{Name: f.Name, Content: f.Content}) } } else { body, err := io.ReadAll(http.MaxBytesReader(c.w, c.r.Body, gistWebFile+1)) if err != nil { c.fail(413, "The file is larger than "+humanSize(gistWebFile)+", push it with git instead") return } name := c.query("name") if name == "" { name = c.query("file") } description, vis = c.query("description"), c.query("visibility") files = []gistFile{{Name: name, Content: string(body)}} } if vis != "" && vis != "public" && vis != "secret" { c.fail(400, "Visibility must be public or secret") return } gist, err := s.createGist(*c.user, description, vis != "public", files) if err != nil { c.err(err) return } doc := s.gistDoc(gist, true) c.w.Header().Set("Location", doc.API) if !strings.HasPrefix(c.r.Header.Get("Content-Type"), "application/json") && !strings.Contains(c.r.Header.Get("Accept"), "application/json") { c.w.Header().Set("Content-Type", "text/plain; charset=utf-8") c.w.WriteHeader(201) io.WriteString(c.w, doc.URL+"\n") return } c.reply(201, doc) } func (s *Server) apiGist(c *call) { if gist, ok := c.gist(); ok { c.reply(200, s.gistDoc(gist, true)) } } func (s *Server) apiPatchGist(c *call) { gist, ok := c.gist() if !ok { return } if c.user.ID != gist.Owner.ID { c.fail(403, "Only the owner can edit this gist") return } var in struct { Description *string `json:"description"` Visibility *string `json:"visibility"` Head string `json:"head"` Files []gistChange `json:"files"` } if !c.decode(&in) { return } description, secret := gist.Description, gist.Secret if in.Description != nil { d, err := cleanDescription(*in.Description) if err != nil { c.err(err) return } description = d } if in.Visibility != nil { if *in.Visibility != "public" && *in.Visibility != "secret" { c.fail(400, "Visibility must be public or secret") return } secret = *in.Visibility == "secret" } if len(in.Files) > 0 { if over, used, quota := overQuota(s.root, gist.Owner.Name); over { c.fail(403, quotaMessage(used, quota)) return } if err := s.changeGist(*c.user, gist, in.Head, in.Files); err != nil { c.err(err) return } } s.store.UpdateGist(gist.ID, description, secret) gist, _ = s.store.Gist(gist.ID) c.reply(200, s.gistDoc(gist, true)) } func (s *Server) apiPutGistFile(c *call) { gist, ok := c.gist() if !ok { return } if c.user.ID != gist.Owner.ID { c.fail(403, "Only the owner can edit this gist") return } change := gistChange{Name: c.arg("name"), Delete: c.r.Method == "DELETE"} if !change.Delete { if over, used, quota := overQuota(s.root, gist.Owner.Name); over { c.fail(403, quotaMessage(used, quota)) return } body, err := io.ReadAll(http.MaxBytesReader(c.w, c.r.Body, gistWebFile+1)) if err != nil { c.fail(413, "The file is larger than "+humanSize(gistWebFile)+", push it with git instead") return } content := string(body) change.Content = &content } if err := s.changeGist(*c.user, gist, c.r.Header.Get("If-Match"), []gistChange{change}); err != nil { c.err(err) return } gist, _ = s.store.Gist(gist.ID) c.reply(200, s.gistDoc(gist, false)) } func (s *Server) apiDeleteGist(c *call) { gist, ok := c.gist() if !ok { return } if err := s.removeGist(*c.user, gist); err != nil { c.err(err) return } c.done() } func (s *Server) apiGistRevisions(c *call) { gist, ok := c.gist() if !ok { return } g := s.gistGit(gist) commits, _ := g.Log(s.gistHead(gist), 1000, "") patch := c.query("patch") == "1" views := mapped(commits, func(cm Commit) commitView { v := s.commitView(cm) if files, err := g.Diff(cm.Hash); err == nil { v.Files = nonNil(fileViews(files, patch)) } return v }) paged(c, views) } func (s *Server) apiStarGist(c *call) { if gist, ok := c.gist(); ok { s.store.StarGist(c.user.ID, gist.ID, c.r.Method == "PUT") c.done() } } func (s *Server) apiGistForks(c *call) { gist, ok := c.gist() if !ok { return } forks, err := s.store.GistForks(gist.ID) if err != nil { c.err(err) return } paged(c, s.gistDocs(forks)) } func (s *Server) apiForkGist(c *call) { gist, ok := c.gist() if !ok { return } fork, err := s.forkGistFor(*c.user, gist) if err != nil { c.err(err) return } c.reply(201, s.gistDoc(fork, false)) } func gistCommentViews(comments []GistComment) []commentView { return mapped(comments, func(gc GistComment) commentView { return commentView{gc.ID, handle(gc.Author.Name), gc.Body, iso(gc.Created)} }) } func (s *Server) apiGistComments(c *call) { if gist, ok := c.gist(); ok { paged(c, gistCommentViews(s.store.GistComments(gist.ID))) } } func (s *Server) apiCommentGist(c *call) { gist, ok := c.gist() if !ok { return } var in struct { Body string `json:"body"` } if !c.decode(&in) { return } if err := s.commentGist(*c.user, gist, in.Body); err != nil { c.err(err) return } comments := s.store.GistComments(gist.ID) c.reply(201, gistCommentViews(comments[len(comments)-1:])[0]) } func (s *Server) apiDropGistComment(c *call) { gist, ok := c.gist() if !ok { return } cid, ok := c.id("cid") if !ok { return } if err := s.dropGistComment(*c.user, gist, cid); err != nil { c.err(err) return } c.done() }