package forge import ( "bufio" "database/sql" "errors" "fmt" "html/template" "io" "os" "path/filepath" "regexp" "slices" "strconv" "strings" "time" "unicode" ) const gistSchema = ` create table if not exists gists ( id text primary key, owner integer not null references users(id) on delete cascade, description text not null default '', secret integer not null default 0, parent text references gists(id) on delete set null, created integer not null, updated integer not null ); create index if not exists gists_owner on gists(owner, updated); create table if not exists gist_stars ( user integer not null references users(id) on delete cascade, gist text not null references gists(id) on delete cascade, created integer not null, primary key (user, gist) ); create table if not exists gist_comments ( id integer primary key, gist text not null references gists(id) on delete cascade, author integer not null references users(id) on delete cascade, body text not null, created integer not null ); ` const ( gistWebFile = 1 << 20 gistPushFile = 10 << 20 gistMaxFiles = 300 gistFormFiles = 30 gistMaxComment = 20000 gistMaxDesc = 300 ) var ( gistID = regexp.MustCompile(`^[0-9a-f]{20}$`) errNoChange = errors.New("nothing changed") errGistEmpty = errors.New("A gist needs at least one file with content") ) type Gist struct { ID string Owner User Description string Secret bool Parent string Created time.Time Updated time.Time Stars int Forks int Comments int } const gistColumns = `g.id, g.description, g.secret, coalesce(g.parent, ''), g.created, g.updated, (select count(*) from gist_stars st where st.gist = g.id), (select count(*) from gists f where f.parent = g.id and f.secret = 0), (select count(*) from gist_comments c where c.gist = g.id), u.id, u.name, u.created from gists g join users u on u.id = g.owner where u.banned = 0` func scanGists(rows *sql.Rows, err error) ([]Gist, error) { if err != nil { return nil, err } defer rows.Close() var out []Gist for rows.Next() { var g Gist var created, updated, ucreated int64 if err := rows.Scan(&g.ID, &g.Description, &g.Secret, &g.Parent, &created, &updated, &g.Stars, &g.Forks, &g.Comments, &g.Owner.ID, &g.Owner.Name, &ucreated); err != nil { return nil, err } g.Created, g.Updated, g.Owner.Created = time.Unix(created, 0), time.Unix(updated, 0), time.Unix(ucreated, 0) out = append(out, g) } return out, rows.Err() } func (s *Store) CreateGist(owner int64, description string, secret bool, parent string) (string, error) { id := random(10) now := time.Now().Unix() _, err := s.db.Exec(`insert into gists(id, owner, description, secret, parent, created, updated) values (?, ?, ?, ?, nullif(?, ''), ?, ?)`, id, owner, description, secret, parent, now, now) return id, err } func (s *Store) Gist(id string) (Gist, error) { gists, err := scanGists(s.db.Query(`select `+gistColumns+` and g.id = ?`, id)) if err != nil || len(gists) == 0 { return Gist{}, ErrNotFound } return gists[0], nil } func (s *Store) Gists(owner int64, withSecret bool, limit int) ([]Gist, error) { return scanGists(s.db.Query(`select `+gistColumns+` and (? = 0 or g.owner = ?) and (g.secret = 0 or ?) order by g.updated desc limit ?`, owner, owner, withSecret, limit)) } func (s *Store) GistForks(id string) ([]Gist, error) { return scanGists(s.db.Query(`select `+gistColumns+` and g.parent = ? and g.secret = 0 order by g.created desc limit 100`, id)) } func (s *Store) StarredGists(user int64) ([]Gist, error) { return scanGists(s.db.Query(`select `+gistColumns+` and g.secret = 0 and g.id in (select gist from gist_stars where user = ?) order by g.updated desc limit 200`, user)) } func (s *Store) GistCount(owner int64, withSecret bool) int { var n int s.db.QueryRow(`select count(*) from gists where owner = ? and (secret = 0 or ?)`, owner, withSecret).Scan(&n) return n } func (s *Store) UpdateGist(id, description string, secret bool) { s.db.Exec(`update gists set description = ?, secret = ?, updated = ? where id = ?`, description, secret, time.Now().Unix(), id) } func (s *Store) TouchGist(id string) { s.db.Exec(`update gists set updated = ? where id = ?`, time.Now().Unix(), id) } func (s *Store) DeleteGist(id string) { s.db.Exec(`delete from gists where id = ?`, id) } func (s *Store) StarGist(user int64, id string, on bool) { if on { s.db.Exec(`insert or ignore into gist_stars(user, gist, created) values (?, ?, ?)`, user, id, time.Now().Unix()) return } s.db.Exec(`delete from gist_stars where user = ? and gist = ?`, user, id) } func (s *Store) GistStarred(user int64, id string) bool { var n int s.db.QueryRow(`select count(*) from gist_stars where user = ? and gist = ?`, user, id).Scan(&n) return n > 0 } type GistComment struct { ID int64 Author User Body string Created time.Time } func (s *Store) AddGistComment(id string, author int64, body string) error { body = strings.TrimSpace(body) if body == "" || len(body) > gistMaxComment { return errors.New("Comment is empty or too long") } _, err := s.db.Exec(`insert into gist_comments(gist, author, body, created) values (?, ?, ?, ?)`, id, author, body, time.Now().Unix()) return err } func (s *Store) GistComments(id string) []GistComment { rows, err := s.db.Query(`select c.id, c.body, c.created, u.id, u.name, u.created from gist_comments c join users u on u.id = c.author where c.gist = ? and u.banned = 0 order by c.id`, id) if err != nil { return nil } defer rows.Close() var out []GistComment for rows.Next() { var c GistComment var created, ucreated int64 if rows.Scan(&c.ID, &c.Body, &created, &c.Author.ID, &c.Author.Name, &ucreated) == nil { c.Created, c.Author.Created = time.Unix(created, 0), time.Unix(ucreated, 0) out = append(out, c) } } return out } func (s *Store) GistCommentAuthor(id string, comment int64) (int64, bool) { var author int64 err := s.db.QueryRow(`select author from gist_comments where id = ? and gist = ?`, comment, id).Scan(&author) return author, err == nil } func (s *Store) DeleteGistComment(comment int64) { s.db.Exec(`delete from gist_comments where id = ?`, comment) } func gistDir(root, owner, id string) string { return filepath.Join(root, "repos", owner, ".gists", id+".git") } func (s *Server) gistGit(g Gist) Git { return Git{Dir: gistDir(s.root, g.Owner.Name, g.ID)} } func (s *Server) rawHost() string { return "gist." + s.host } func gistEnv(g Gist, pusher User) []string { return []string{"GITPOCKET_GIST=" + g.ID, "GITPOCKET_PUSHER=" + strconv.FormatInt(pusher.ID, 10)} } func gistReceiveArgs(root, owner string) []string { return append([]string{"-c", "core.hooksPath=" + hooksDir(root)}, pushLimit(root, owner, true)...) } type gistEntry struct { Mode string Type string OID string Size int64 Name string } func (g Git) gistTree(commit string) ([]gistEntry, error) { out, err := g.run(nil, "ls-tree", "-z", "-l", "--full-tree", commit) if err != nil { return nil, err } var entries []gistEntry for _, rec := range strings.Split(strings.TrimSuffix(string(out), "\x00"), "\x00") { meta, name, ok := strings.Cut(rec, "\t") f := strings.Fields(meta) if !ok || len(f) != 4 { continue } size, _ := strconv.ParseInt(f[3], 10, 64) entries = append(entries, gistEntry{Mode: f[0], Type: f[1], OID: f[2], Size: size, Name: name}) } return entries, nil } func checkGistName(name string) error { if name == "" || len(name) > 100 || name == "." || name == ".." || strings.ContainsAny(name, `/\`) || strings.ContainsFunc(name, unicode.IsControl) { return fmt.Errorf("%q is not a usable file name, names cannot contain slashes and are at most 100 bytes", name) } return nil } func checkGistEntries(entries []gistEntry) error { if len(entries) == 0 { return errGistEmpty } if len(entries) > gistMaxFiles { return fmt.Errorf("a gist holds at most %d files", gistMaxFiles) } for _, e := range entries { if e.Type != "blob" || (e.Mode != "100644" && e.Mode != "100755") { return fmt.Errorf("%s is not a plain file, gists cannot hold directories, symlinks or submodules", e.Name) } if err := checkGistName(e.Name); err != nil { return err } if e.Size > gistPushFile { return fmt.Errorf("%s is larger than %s", e.Name, humanSize(gistPushFile)) } } return nil } func gistPreReceive(root, id string, in io.Reader, errw io.Writer) int { refuse := func(format string, args ...any) int { fmt.Fprintf(errw, "gitpocket: "+format+"\n", args...) return 1 } pusher, err := strconv.ParseInt(os.Getenv("GITPOCKET_PUSHER"), 10, 64) if err != nil || !gistID.MatchString(id) { return refuse("push did not come through gitpocket") } store, err := openStore(filepath.Join(root, "gitpocket.db"), true) if err != nil { return refuse("database unavailable, push refused") } gist, err := store.Gist(id) if err != nil || gist.Owner.ID != pusher || store.IsBanned(pusher) { return refuse("only the owner can push to this gist") } g := Git{Dir: gistDir(root, gist.Owner.Name, gist.ID)} scanner := bufio.NewScanner(in) for scanner.Scan() { f := strings.Fields(scanner.Text()) if len(f) != 3 { return refuse("malformed update") } if f[2] != "refs/heads/main" { return refuse("gists only have a main branch, push to main instead of %s", f[2]) } if f[1] == zeroOID { return refuse("main cannot be deleted, delete the gist from its page instead") } entries, err := g.gistTree(f[1]) if err != nil { return refuse("cannot read the pushed tree") } if err := checkGistEntries(entries); err != nil { return refuse("%v", err) } } return 0 } type gistFile struct { Orig string Name string Content string } func (s *Server) writeGist(g Git, parent string, files []gistFile, keep []gistEntry, u User, message string) (string, error) { entries := slices.Clone(keep) modes := map[string]string{} if parent != "" { if old, err := g.gistTree(parent); err == nil { for _, e := range old { modes[e.Name] = e.Mode } } } taken := map[string]bool{} for _, e := range entries { taken[e.Name] = true } next := 1 for _, f := range files { if f.Content == "" { continue } name := strings.TrimSpace(f.Name) if name == "" { for taken[fmt.Sprintf("gistfile%d.txt", next)] { next++ } name = fmt.Sprintf("gistfile%d.txt", next) } if err := checkGistName(name); err != nil { return "", err } if taken[name] { return "", fmt.Errorf("%s appears twice", name) } if len(f.Content) > gistWebFile { return "", fmt.Errorf("%s is larger than %s, push it with git instead", name, humanSize(gistWebFile)) } taken[name] = true oid, err := g.run([]byte(f.Content), "hash-object", "-w", "--stdin") if err != nil { return "", err } mode := modes[f.Orig] if mode == "" { mode = "100644" } entries = append(entries, gistEntry{Mode: mode, Type: "blob", OID: strings.TrimSpace(string(oid)), Size: int64(len(f.Content)), Name: name}) } if err := checkGistEntries(entries); err != nil { return "", err } var input strings.Builder for _, e := range entries { fmt.Fprintf(&input, "%s blob %s\t%s\x00", e.Mode, e.OID, e.Name) } tree, err := g.run([]byte(input.String()), "mktree", "-z") if err != nil { return "", err } treeID := strings.TrimSpace(string(tree)) args := []string{"commit-tree", treeID} if parent != "" { if old, err := g.run(nil, "rev-parse", parent+"^{tree}"); err == nil && strings.TrimSpace(string(old)) == treeID { return parent, errNoChange } args = append(args, "-p", parent) } commit, err := g.runEnv(ident(u.Name, s.noreply(u), timeNow()), []byte(message), args...) if err != nil { return "", err } hash := strings.TrimSpace(string(commit)) update := []string{"update-ref", "refs/heads/main", hash} if parent != "" { update = append(update, parent) } if _, err := g.run(nil, update...); err != nil { return "", errors.New("The gist changed while you were editing it, reload and try again") } return hash, nil } func (s *Server) gistAllowed(u User) error { if time.Since(u.Created) < pagesMinAge { return errors.New("Accounts can create gists once they are a day old") } if s.uploads.blocked("gist:" + u.Name) { return errors.New("Too many new gists, try again later") } if over, used, quota := overQuota(s.root, u.Name); over { return errors.New(quotaMessage(used, quota)) } return nil } func cleanDescription(d string) (string, error) { d = strings.TrimSpace(strings.ReplaceAll(strings.ReplaceAll(d, "\r", " "), "\n", " ")) if len(d) > gistMaxDesc { return "", fmt.Errorf("Descriptions are at most %d characters", gistMaxDesc) } return d, nil } func (s *Server) createGist(u User, description string, secret bool, files []gistFile) (Gist, error) { if err := s.gistAllowed(u); err != nil { return Gist{}, err } description, err := cleanDescription(description) if err != nil { return Gist{}, err } if !slices.ContainsFunc(files, func(f gistFile) bool { return f.Content != "" }) { return Gist{}, errGistEmpty } id, err := s.store.CreateGist(u.ID, description, secret, "") if err != nil { return Gist{}, err } dir := gistDir(s.root, u.Name, id) undo := func(err error) (Gist, error) { s.store.DeleteGist(id) os.RemoveAll(dir) return Gist{}, err } if err := os.MkdirAll(filepath.Dir(dir), 0o755); err != nil { return undo(err) } if err := initRepo(dir); err != nil { return undo(err) } if _, err := s.writeGist(Git{Dir: dir}, "", files, nil, u, "Create gist"); err != nil { return undo(err) } s.uploads.fail("gist:" + u.Name) return s.store.Gist(id) } func (s *Server) forkGist(u User, src Gist) (Gist, error) { if err := s.gistAllowed(u); err != nil { return Gist{}, err } id, err := s.store.CreateGist(u.ID, src.Description, src.Secret, src.ID) if err != nil { return Gist{}, err } dir := gistDir(s.root, u.Name, id) os.MkdirAll(filepath.Dir(dir), 0o755) if err := cloneRepo(s.gistGit(src).Dir, dir); err != nil { s.store.DeleteGist(id) os.RemoveAll(dir) return Gist{}, err } s.uploads.fail("gist:" + u.Name) return s.store.Gist(id) } type gistView struct { Name string Size int64 Raw string Image bool Binary bool Large bool Markdown template.HTML Highlighted template.HTML Content []byte } func (s *Server) rawURL(g Gist, commit, name string) string { return "https://" + s.rawHost() + "/raw/" + g.ID + "/" + commit + "/" + escapePath(name) } func (s *Server) gistViews(gist Gist, commit string, preview int) []gistView { g := s.gistGit(gist) entries, err := g.gistTree(commit) if err != nil { return nil } if preview > 0 && len(entries) > 1 { entries = entries[:1] } var views []gistView for _, e := range entries { v := gistView{Name: e.Name, Size: e.Size, Raw: s.rawURL(gist, commit, e.Name)} if kind := mediaTypes[strings.ToLower(filepath.Ext(e.Name))]; strings.HasPrefix(kind, "image/") { v.Image = true views = append(views, v) continue } if e.Size > maxRender { v.Large = true views = append(views, v) continue } content, err := g.run(nil, "cat-file", "blob", e.OID) if err != nil { continue } switch { case isBinary(content): v.Binary = true case preview > 0: lines := strings.SplitAfter(string(content), "\n") if len(lines) > preview { content = []byte(strings.Join(lines[:preview], "")) } if h, ok := highlightLimited(e.Name, content); ok { v.Highlighted = h } else { v.Content = content } case strings.HasSuffix(strings.ToLower(e.Name), ".md"): v.Markdown = s.renderMarkdown(content) default: if h, ok := highlightLimited(e.Name, content); ok { v.Highlighted = h } else { v.Content = content } } views = append(views, v) } return views } func (s *Server) gistHead(g Gist) string { head, _ := s.gistGit(g).Resolve("refs/heads/main") return head } type gistItem struct { Gist Gist Files []string Preview []gistView } func (s *Server) gistItems(gists []Gist) []gistItem { items := make([]gistItem, 0, len(gists)) for _, g := range gists { head := s.gistHead(g) if head == "" { continue } item := gistItem{Gist: g, Preview: s.gistViews(g, head, 8)} if entries, err := s.gistGit(g).gistTree(head); err == nil { for _, e := range entries { item.Files = append(item.Files, e.Name) } } items = append(items, item) } return items } func (g Gist) Title(files []string) string { if g.Description != "" { return g.Description } if len(files) > 0 { return files[0] } return "gist " + g.ID[:8] }