~gitpocket/gitpocket

8

feat(gists): host multi-file gists as git repositories with a cookie-less raw origin

commit
9c68a68a5bb0145933c82204fa4017d4d2681a4f browse tree
parent
5bdc6f72f732cf035f27e6d12f3b66b426568532
author
~james <354576047+[email protected]>
signature
Signed by: ~james SHA256:KlzJ0P0gvJcnOYpaxD5yCUw52ImHttTKRjVAEnnYnKg
date
2026-11-14 12:17 UTC

20 files changed, +1585 −12

forge/agit.go modified

@@ -122,15 +122,18 @@ func preReceive(root string, in io.Reader, errw io.Writer) int {
122122 fmt.Fprintf(errw, "gitpocket: "+format+"\n", args...)
123123 return 1
124124 }
125- hc, err := loadHook(root, true)
126- if err != nil {
127- return refuse("%v, push refused", err)
128- }
129125 for _, kv := range os.Environ() {
130126 if strings.HasPrefix(kv, "GIT_OBJECT_DIRECTORY=") || strings.HasPrefix(kv, "GIT_ALTERNATE_OBJECT_DIRECTORIES=") || strings.HasPrefix(kv, "GIT_QUARANTINE_PATH=") {
131127 passEnv = append(passEnv, kv)
132128 }
133129 }
130+ if id := os.Getenv("GITPOCKET_GIST"); id != "" {
131+ return gistPreReceive(root, id, in, errw)
132+ }
133+ hc, err := loadHook(root, true)
134+ if err != nil {
135+ return refuse("%v, push refused", err)
136+ }
134137 mirror := hc.store.IsPullMirror(hc.repo.ID)
135138 if !hc.writer && (!hc.store.GuestPushBudget(hc.pusher.ID) || hc.store.Blocked(hc.repo.Owner.ID, hc.pusher.ID) || hc.repo.Archived || mirror) {
136139 return refuse("you cannot open pull requests here right now")

forge/backup.go modified

@@ -28,6 +28,8 @@ func backup(root, dir string, keep int) error {
2828 }
2929 var failed []string
3030 repos, _ := filepath.Glob(filepath.Join(root, "repos", "*", "*.git"))
31+ gists, _ := filepath.Glob(filepath.Join(root, "repos", "*", ".gists", "*.git"))
32+ repos = append(repos, gists...)
3133 live := map[string]bool{}
3234 for _, repo := range repos {
3335 rel, _ := filepath.Rel(filepath.Join(root, "repos"), repo)
@@ -41,6 +43,8 @@ func backup(root, dir string, keep int) error {
4143 }
4244 }
4345 stale, _ := filepath.Glob(filepath.Join(dir, "repos", "*", "*.bundle"))
46+ staleGists, _ := filepath.Glob(filepath.Join(dir, "repos", "*", ".gists", "*.bundle"))
47+ stale = append(stale, staleGists...)
4448 for _, b := range stale {
4549 if !live[b] {
4650 os.Remove(b)

forge/gist.go added

@@ -0,0 +1,578 @@
1+package forge
2+
3+import (
4+ "bufio"
5+ "database/sql"
6+ "errors"
7+ "fmt"
8+ "html/template"
9+ "io"
10+ "os"
11+ "path/filepath"
12+ "regexp"
13+ "slices"
14+ "strconv"
15+ "strings"
16+ "time"
17+ "unicode"
18+)
19+
20+const gistSchema = `
21+create table if not exists gists (
22+ id text primary key,
23+ owner integer not null references users(id) on delete cascade,
24+ description text not null default '',
25+ secret integer not null default 0,
26+ parent text references gists(id) on delete set null,
27+ created integer not null,
28+ updated integer not null
29+);
30+create index if not exists gists_owner on gists(owner, updated);
31+create table if not exists gist_stars (
32+ user integer not null references users(id) on delete cascade,
33+ gist text not null references gists(id) on delete cascade,
34+ created integer not null,
35+ primary key (user, gist)
36+);
37+create table if not exists gist_comments (
38+ id integer primary key,
39+ gist text not null references gists(id) on delete cascade,
40+ author integer not null references users(id) on delete cascade,
41+ body text not null,
42+ created integer not null
43+);
44+`
45+
46+const (
47+ gistWebFile = 1 << 20
48+ gistPushFile = 10 << 20
49+ gistMaxFiles = 300
50+ gistFormFiles = 30
51+ gistMaxComment = 20000
52+ gistMaxDesc = 300
53+)
54+
55+var (
56+ gistID = regexp.MustCompile(`^[0-9a-f]{20}$`)
57+ errNoChange = errors.New("nothing changed")
58+ errGistEmpty = errors.New("A gist needs at least one file with content")
59+)
60+
61+type Gist struct {
62+ ID string
63+ Owner User
64+ Description string
65+ Secret bool
66+ Parent string
67+ Created time.Time
68+ Updated time.Time
69+ Stars int
70+ Forks int
71+ Comments int
72+}
73+
74+const gistColumns = `g.id, g.description, g.secret, coalesce(g.parent, ''), g.created, g.updated,
75+ (select count(*) from gist_stars st where st.gist = g.id),
76+ (select count(*) from gists f where f.parent = g.id and f.secret = 0),
77+ (select count(*) from gist_comments c where c.gist = g.id),
78+ u.id, u.name, u.created from gists g join users u on u.id = g.owner where u.banned = 0`
79+
80+func scanGists(rows *sql.Rows, err error) ([]Gist, error) {
81+ if err != nil {
82+ return nil, err
83+ }
84+ defer rows.Close()
85+ var out []Gist
86+ for rows.Next() {
87+ var g Gist
88+ var created, updated, ucreated int64
89+ 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 {
90+ return nil, err
91+ }
92+ g.Created, g.Updated, g.Owner.Created = time.Unix(created, 0), time.Unix(updated, 0), time.Unix(ucreated, 0)
93+ out = append(out, g)
94+ }
95+ return out, rows.Err()
96+}
97+
98+func (s *Store) CreateGist(owner int64, description string, secret bool, parent string) (string, error) {
99+ id := random(10)
100+ now := time.Now().Unix()
101+ _, err := s.db.Exec(`insert into gists(id, owner, description, secret, parent, created, updated) values (?, ?, ?, ?, nullif(?, ''), ?, ?)`, id, owner, description, secret, parent, now, now)
102+ return id, err
103+}
104+
105+func (s *Store) Gist(id string) (Gist, error) {
106+ gists, err := scanGists(s.db.Query(`select `+gistColumns+` and g.id = ?`, id))
107+ if err != nil || len(gists) == 0 {
108+ return Gist{}, ErrNotFound
109+ }
110+ return gists[0], nil
111+}
112+
113+func (s *Store) Gists(owner int64, withSecret bool, limit int) ([]Gist, error) {
114+ 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))
115+}
116+
117+func (s *Store) GistForks(id string) ([]Gist, error) {
118+ return scanGists(s.db.Query(`select `+gistColumns+` and g.parent = ? and g.secret = 0 order by g.created desc limit 100`, id))
119+}
120+
121+func (s *Store) StarredGists(user int64) ([]Gist, error) {
122+ 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))
123+}
124+
125+func (s *Store) GistCount(owner int64, withSecret bool) int {
126+ var n int
127+ s.db.QueryRow(`select count(*) from gists where owner = ? and (secret = 0 or ?)`, owner, withSecret).Scan(&n)
128+ return n
129+}
130+
131+func (s *Store) UpdateGist(id, description string, secret bool) {
132+ s.db.Exec(`update gists set description = ?, secret = ?, updated = ? where id = ?`, description, secret, time.Now().Unix(), id)
133+}
134+
135+func (s *Store) TouchGist(id string) {
136+ s.db.Exec(`update gists set updated = ? where id = ?`, time.Now().Unix(), id)
137+}
138+
139+func (s *Store) DeleteGist(id string) {
140+ s.db.Exec(`delete from gists where id = ?`, id)
141+}
142+
143+func (s *Store) StarGist(user int64, id string, on bool) {
144+ if on {
145+ s.db.Exec(`insert or ignore into gist_stars(user, gist, created) values (?, ?, ?)`, user, id, time.Now().Unix())
146+ return
147+ }
148+ s.db.Exec(`delete from gist_stars where user = ? and gist = ?`, user, id)
149+}
150+
151+func (s *Store) GistStarred(user int64, id string) bool {
152+ var n int
153+ s.db.QueryRow(`select count(*) from gist_stars where user = ? and gist = ?`, user, id).Scan(&n)
154+ return n > 0
155+}
156+
157+type GistComment struct {
158+ ID int64
159+ Author User
160+ Body string
161+ Created time.Time
162+}
163+
164+func (s *Store) AddGistComment(id string, author int64, body string) error {
165+ body = strings.TrimSpace(body)
166+ if body == "" || len(body) > gistMaxComment {
167+ return errors.New("Comment is empty or too long")
168+ }
169+ _, err := s.db.Exec(`insert into gist_comments(gist, author, body, created) values (?, ?, ?, ?)`, id, author, body, time.Now().Unix())
170+ return err
171+}
172+
173+func (s *Store) GistComments(id string) []GistComment {
174+ 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)
175+ if err != nil {
176+ return nil
177+ }
178+ defer rows.Close()
179+ var out []GistComment
180+ for rows.Next() {
181+ var c GistComment
182+ var created, ucreated int64
183+ if rows.Scan(&c.ID, &c.Body, &created, &c.Author.ID, &c.Author.Name, &ucreated) == nil {
184+ c.Created, c.Author.Created = time.Unix(created, 0), time.Unix(ucreated, 0)
185+ out = append(out, c)
186+ }
187+ }
188+ return out
189+}
190+
191+func (s *Store) GistCommentAuthor(id string, comment int64) (int64, bool) {
192+ var author int64
193+ err := s.db.QueryRow(`select author from gist_comments where id = ? and gist = ?`, comment, id).Scan(&author)
194+ return author, err == nil
195+}
196+
197+func (s *Store) DeleteGistComment(comment int64) {
198+ s.db.Exec(`delete from gist_comments where id = ?`, comment)
199+}
200+
201+func gistDir(root, owner, id string) string {
202+ return filepath.Join(root, "repos", owner, ".gists", id+".git")
203+}
204+
205+func (s *Server) gistGit(g Gist) Git { return Git{Dir: gistDir(s.root, g.Owner.Name, g.ID)} }
206+
207+func (s *Server) rawHost() string { return "gist." + s.host }
208+
209+func gistEnv(g Gist, pusher User) []string {
210+ return []string{"GITPOCKET_GIST=" + g.ID, "GITPOCKET_PUSHER=" + strconv.FormatInt(pusher.ID, 10)}
211+}
212+
213+func gistReceiveArgs(root, owner string) []string {
214+ return append([]string{"-c", "core.hooksPath=" + hooksDir(root)}, pushLimit(root, owner, true)...)
215+}
216+
217+type gistEntry struct {
218+ Mode string
219+ Type string
220+ OID string
221+ Size int64
222+ Name string
223+}
224+
225+func (g Git) gistTree(commit string) ([]gistEntry, error) {
226+ out, err := g.run(nil, "ls-tree", "-z", "-l", "--full-tree", commit)
227+ if err != nil {
228+ return nil, err
229+ }
230+ var entries []gistEntry
231+ for _, rec := range strings.Split(strings.TrimSuffix(string(out), "\x00"), "\x00") {
232+ meta, name, ok := strings.Cut(rec, "\t")
233+ f := strings.Fields(meta)
234+ if !ok || len(f) != 4 {
235+ continue
236+ }
237+ size, _ := strconv.ParseInt(f[3], 10, 64)
238+ entries = append(entries, gistEntry{Mode: f[0], Type: f[1], OID: f[2], Size: size, Name: name})
239+ }
240+ return entries, nil
241+}
242+
243+func checkGistName(name string) error {
244+ if name == "" || len(name) > 100 || name == "." || name == ".." || strings.ContainsAny(name, `/\`) || strings.ContainsFunc(name, unicode.IsControl) {
245+ return fmt.Errorf("%q is not a usable file name, names cannot contain slashes and are at most 100 bytes", name)
246+ }
247+ return nil
248+}
249+
250+func checkGistEntries(entries []gistEntry) error {
251+ if len(entries) == 0 {
252+ return errGistEmpty
253+ }
254+ if len(entries) > gistMaxFiles {
255+ return fmt.Errorf("a gist holds at most %d files", gistMaxFiles)
256+ }
257+ for _, e := range entries {
258+ if e.Type != "blob" || (e.Mode != "100644" && e.Mode != "100755") {
259+ return fmt.Errorf("%s is not a plain file, gists cannot hold directories, symlinks or submodules", e.Name)
260+ }
261+ if err := checkGistName(e.Name); err != nil {
262+ return err
263+ }
264+ if e.Size > gistPushFile {
265+ return fmt.Errorf("%s is larger than %s", e.Name, humanSize(gistPushFile))
266+ }
267+ }
268+ return nil
269+}
270+
271+func gistPreReceive(root, id string, in io.Reader, errw io.Writer) int {
272+ refuse := func(format string, args ...any) int {
273+ fmt.Fprintf(errw, "gitpocket: "+format+"\n", args...)
274+ return 1
275+ }
276+ pusher, err := strconv.ParseInt(os.Getenv("GITPOCKET_PUSHER"), 10, 64)
277+ if err != nil || !gistID.MatchString(id) {
278+ return refuse("push did not come through gitpocket")
279+ }
280+ store, err := openStore(filepath.Join(root, "gitpocket.db"), true)
281+ if err != nil {
282+ return refuse("database unavailable, push refused")
283+ }
284+ gist, err := store.Gist(id)
285+ if err != nil || gist.Owner.ID != pusher || store.IsBanned(pusher) {
286+ return refuse("only the owner can push to this gist")
287+ }
288+ g := Git{Dir: gistDir(root, gist.Owner.Name, gist.ID)}
289+ scanner := bufio.NewScanner(in)
290+ for scanner.Scan() {
291+ f := strings.Fields(scanner.Text())
292+ if len(f) != 3 {
293+ return refuse("malformed update")
294+ }
295+ if f[2] != "refs/heads/main" {
296+ return refuse("gists only have a main branch, push to main instead of %s", f[2])
297+ }
298+ if f[1] == zeroOID {
299+ return refuse("main cannot be deleted, delete the gist from its page instead")
300+ }
301+ entries, err := g.gistTree(f[1])
302+ if err != nil {
303+ return refuse("cannot read the pushed tree")
304+ }
305+ if err := checkGistEntries(entries); err != nil {
306+ return refuse("%v", err)
307+ }
308+ }
309+ return 0
310+}
311+
312+type gistFile struct {
313+ Orig string
314+ Name string
315+ Content string
316+}
317+
318+func (s *Server) writeGist(g Git, parent string, files []gistFile, keep []gistEntry, u User, message string) (string, error) {
319+ entries := slices.Clone(keep)
320+ modes := map[string]string{}
321+ if parent != "" {
322+ if old, err := g.gistTree(parent); err == nil {
323+ for _, e := range old {
324+ modes[e.Name] = e.Mode
325+ }
326+ }
327+ }
328+ taken := map[string]bool{}
329+ for _, e := range entries {
330+ taken[e.Name] = true
331+ }
332+ next := 1
333+ for _, f := range files {
334+ if f.Content == "" {
335+ continue
336+ }
337+ name := strings.TrimSpace(f.Name)
338+ if name == "" {
339+ for taken[fmt.Sprintf("gistfile%d.txt", next)] {
340+ next++
341+ }
342+ name = fmt.Sprintf("gistfile%d.txt", next)
343+ }
344+ if err := checkGistName(name); err != nil {
345+ return "", err
346+ }
347+ if taken[name] {
348+ return "", fmt.Errorf("%s appears twice", name)
349+ }
350+ if len(f.Content) > gistWebFile {
351+ return "", fmt.Errorf("%s is larger than %s, push it with git instead", name, humanSize(gistWebFile))
352+ }
353+ taken[name] = true
354+ oid, err := g.run([]byte(f.Content), "hash-object", "-w", "--stdin")
355+ if err != nil {
356+ return "", err
357+ }
358+ mode := modes[f.Orig]
359+ if mode == "" {
360+ mode = "100644"
361+ }
362+ entries = append(entries, gistEntry{Mode: mode, Type: "blob", OID: strings.TrimSpace(string(oid)), Size: int64(len(f.Content)), Name: name})
363+ }
364+ if err := checkGistEntries(entries); err != nil {
365+ return "", err
366+ }
367+ var input strings.Builder
368+ for _, e := range entries {
369+ fmt.Fprintf(&input, "%s blob %s\t%s\x00", e.Mode, e.OID, e.Name)
370+ }
371+ tree, err := g.run([]byte(input.String()), "mktree", "-z")
372+ if err != nil {
373+ return "", err
374+ }
375+ treeID := strings.TrimSpace(string(tree))
376+ args := []string{"commit-tree", treeID}
377+ if parent != "" {
378+ if old, err := g.run(nil, "rev-parse", parent+"^{tree}"); err == nil && strings.TrimSpace(string(old)) == treeID {
379+ return parent, errNoChange
380+ }
381+ args = append(args, "-p", parent)
382+ }
383+ commit, err := g.runEnv(ident(u.Name, s.noreply(u), timeNow()), []byte(message), args...)
384+ if err != nil {
385+ return "", err
386+ }
387+ hash := strings.TrimSpace(string(commit))
388+ update := []string{"update-ref", "refs/heads/main", hash}
389+ if parent != "" {
390+ update = append(update, parent)
391+ }
392+ if _, err := g.run(nil, update...); err != nil {
393+ return "", errors.New("The gist changed while you were editing it, reload and try again")
394+ }
395+ return hash, nil
396+}
397+
398+func (s *Server) gistAllowed(u User) error {
399+ if time.Since(u.Created) < pagesMinAge {
400+ return errors.New("Accounts can create gists once they are a day old")
401+ }
402+ if s.uploads.blocked("gist:" + u.Name) {
403+ return errors.New("Too many new gists, try again later")
404+ }
405+ if over, used, quota := overQuota(s.root, u.Name); over {
406+ return errors.New(quotaMessage(used, quota))
407+ }
408+ return nil
409+}
410+
411+func cleanDescription(d string) (string, error) {
412+ d = strings.TrimSpace(strings.ReplaceAll(strings.ReplaceAll(d, "\r", " "), "\n", " "))
413+ if len(d) > gistMaxDesc {
414+ return "", fmt.Errorf("Descriptions are at most %d characters", gistMaxDesc)
415+ }
416+ return d, nil
417+}
418+
419+func (s *Server) createGist(u User, description string, secret bool, files []gistFile) (Gist, error) {
420+ if err := s.gistAllowed(u); err != nil {
421+ return Gist{}, err
422+ }
423+ description, err := cleanDescription(description)
424+ if err != nil {
425+ return Gist{}, err
426+ }
427+ if !slices.ContainsFunc(files, func(f gistFile) bool { return f.Content != "" }) {
428+ return Gist{}, errGistEmpty
429+ }
430+ id, err := s.store.CreateGist(u.ID, description, secret, "")
431+ if err != nil {
432+ return Gist{}, err
433+ }
434+ dir := gistDir(s.root, u.Name, id)
435+ undo := func(err error) (Gist, error) {
436+ s.store.DeleteGist(id)
437+ os.RemoveAll(dir)
438+ return Gist{}, err
439+ }
440+ if err := os.MkdirAll(filepath.Dir(dir), 0o755); err != nil {
441+ return undo(err)
442+ }
443+ if err := initRepo(dir); err != nil {
444+ return undo(err)
445+ }
446+ if _, err := s.writeGist(Git{Dir: dir}, "", files, nil, u, "Create gist"); err != nil {
447+ return undo(err)
448+ }
449+ s.uploads.fail("gist:" + u.Name)
450+ return s.store.Gist(id)
451+}
452+
453+func (s *Server) forkGist(u User, src Gist) (Gist, error) {
454+ if err := s.gistAllowed(u); err != nil {
455+ return Gist{}, err
456+ }
457+ id, err := s.store.CreateGist(u.ID, src.Description, src.Secret, src.ID)
458+ if err != nil {
459+ return Gist{}, err
460+ }
461+ dir := gistDir(s.root, u.Name, id)
462+ os.MkdirAll(filepath.Dir(dir), 0o755)
463+ if err := cloneRepo(s.gistGit(src).Dir, dir); err != nil {
464+ s.store.DeleteGist(id)
465+ os.RemoveAll(dir)
466+ return Gist{}, err
467+ }
468+ s.uploads.fail("gist:" + u.Name)
469+ return s.store.Gist(id)
470+}
471+
472+type gistView struct {
473+ Name string
474+ Size int64
475+ Raw string
476+ Image bool
477+ Binary bool
478+ Large bool
479+ Markdown template.HTML
480+ Highlighted template.HTML
481+ Content []byte
482+}
483+
484+func (s *Server) rawURL(g Gist, commit, name string) string {
485+ return "https://" + s.rawHost() + "/raw/" + g.ID + "/" + commit + "/" + escapePath(name)
486+}
487+
488+func (s *Server) gistViews(gist Gist, commit string, preview int) []gistView {
489+ g := s.gistGit(gist)
490+ entries, err := g.gistTree(commit)
491+ if err != nil {
492+ return nil
493+ }
494+ if preview > 0 && len(entries) > 1 {
495+ entries = entries[:1]
496+ }
497+ var views []gistView
498+ for _, e := range entries {
499+ v := gistView{Name: e.Name, Size: e.Size, Raw: s.rawURL(gist, commit, e.Name)}
500+ if kind := mediaTypes[strings.ToLower(filepath.Ext(e.Name))]; strings.HasPrefix(kind, "image/") {
501+ v.Image = true
502+ views = append(views, v)
503+ continue
504+ }
505+ if e.Size > maxRender {
506+ v.Large = true
507+ views = append(views, v)
508+ continue
509+ }
510+ content, err := g.run(nil, "cat-file", "blob", e.OID)
511+ if err != nil {
512+ continue
513+ }
514+ switch {
515+ case isBinary(content):
516+ v.Binary = true
517+ case preview > 0:
518+ lines := strings.SplitAfter(string(content), "\n")
519+ if len(lines) > preview {
520+ content = []byte(strings.Join(lines[:preview], ""))
521+ }
522+ if h, ok := highlightLimited(e.Name, content); ok {
523+ v.Highlighted = h
524+ } else {
525+ v.Content = content
526+ }
527+ case strings.HasSuffix(strings.ToLower(e.Name), ".md"):
528+ v.Markdown = s.renderMarkdown(content)
529+ default:
530+ if h, ok := highlightLimited(e.Name, content); ok {
531+ v.Highlighted = h
532+ } else {
533+ v.Content = content
534+ }
535+ }
536+ views = append(views, v)
537+ }
538+ return views
539+}
540+
541+func (s *Server) gistHead(g Gist) string {
542+ head, _ := s.gistGit(g).Resolve("refs/heads/main")
543+ return head
544+}
545+
546+type gistItem struct {
547+ Gist Gist
548+ Files []string
549+ Preview []gistView
550+}
551+
552+func (s *Server) gistItems(gists []Gist) []gistItem {
553+ items := make([]gistItem, 0, len(gists))
554+ for _, g := range gists {
555+ head := s.gistHead(g)
556+ if head == "" {
557+ continue
558+ }
559+ item := gistItem{Gist: g, Preview: s.gistViews(g, head, 8)}
560+ if entries, err := s.gistGit(g).gistTree(head); err == nil {
561+ for _, e := range entries {
562+ item.Files = append(item.Files, e.Name)
563+ }
564+ }
565+ items = append(items, item)
566+ }
567+ return items
568+}
569+
570+func (g Gist) Title(files []string) string {
571+ if g.Description != "" {
572+ return g.Description
573+ }
574+ if len(files) > 0 {
575+ return files[0]
576+ }
577+ return "gist " + g.ID[:8]
578+}

forge/gist_test.go added

@@ -0,0 +1,187 @@
1+package forge
2+
3+import (
4+ "bytes"
5+ "encoding/json"
6+ "net/http"
7+ "net/http/httptest"
8+ "net/url"
9+ "os/exec"
10+ "strconv"
11+ "strings"
12+ "testing"
13+)
14+
15+func gistLogin(t *testing.T, s *Server, h http.Handler, name string) (User, string) {
16+ t.Helper()
17+ u, err := s.store.CreateUser(name, "correct horse")
18+ if err != nil {
19+ t.Fatal(err)
20+ }
21+ w := post(h, "/login", url.Values{"name": {name}, "password": {"correct horse"}, "email": {name + "@example.org"}}, "")
22+ c := session(w)
23+ if c == "" {
24+ t.Fatalf("login %s: %d", name, w.Code)
25+ }
26+ return u, c
27+}
28+
29+func newGist(t *testing.T, h http.Handler, cookie string, form url.Values) string {
30+ t.Helper()
31+ w := post(h, "/gists", form, cookie)
32+ id := strings.TrimPrefix(w.Header().Get("Location"), "/gist/")
33+ if w.Code != 303 || !gistID.MatchString(id) {
34+ t.Fatalf("create gist: %d %s", w.Code, w.Body.String())
35+ }
36+ return id
37+}
38+
39+func TestGists(t *testing.T) {
40+ if _, err := exec.LookPath("git"); err != nil {
41+ t.Skip("git not installed")
42+ }
43+ s, h := testServer(t)
44+ alice, ac := gistLogin(t, s, h, "alice")
45+ bob, bc := gistLogin(t, s, h, "bob")
46+ files := url.Values{"name": {"hello.go", ""}, "content": {"package main\r\n", "plain notes\n"}, "orig": {"", ""}, "description": {"first"}}
47+ if w := post(h, "/gists", files, ac); w.Code != 400 || !strings.Contains(w.Body.String(), "a day old") {
48+ t.Fatalf("young account created a gist: %d", w.Code)
49+ }
50+ age(t, s, alice)
51+ age(t, s, bob)
52+ id := newGist(t, h, ac, files)
53+ page := get(h, "/gist/"+id, bc).Body.String()
54+ for _, want := range []string{"hello.go", "gistfile1.txt", "first", "/report?kind=gist"} {
55+ if !strings.Contains(page, want) {
56+ t.Errorf("gist page lacks %q", want)
57+ }
58+ }
59+ raw := pagesGet(h, "gist.example.org", "/raw/"+id+"/hello.go")
60+ if raw.Body.String() != "package main\n" || !strings.HasPrefix(raw.Header().Get("Content-Type"), "text/plain") || !strings.Contains(raw.Header().Get("Content-Security-Policy"), "sandbox") || len(raw.Result().Cookies()) > 0 {
61+ t.Errorf("raw: %d %q %v", raw.Code, raw.Body.String(), raw.Header())
62+ }
63+ if w := pagesGet(h, "gist.example.org", "/gists"); w.Code == 200 {
64+ t.Error("raw host serves the web app")
65+ }
66+ if w := pagesGet(h, "gist.example.org", "/embed/"+id); !strings.Contains(w.Body.String(), "hello.go") || !strings.Contains(w.Header().Get("Content-Security-Policy"), "frame-ancestors *") {
67+ t.Errorf("embed: %d %v", w.Code, w.Header())
68+ }
69+
70+ secret := newGist(t, h, ac, url.Values{"name": {"key.txt"}, "content": {"hidden"}, "orig": {""}, "visibility": {"secret"}})
71+ for path, cookie := range map[string]string{"/gists": ac, "/~alice/gists": bc} {
72+ if strings.Contains(get(h, path, cookie).Body.String(), secret) {
73+ t.Errorf("%s lists a secret gist", path)
74+ }
75+ }
76+ if !strings.Contains(get(h, "/~alice/gists", ac).Body.String(), secret) {
77+ t.Error("owner cannot see their secret gist")
78+ }
79+
80+ for _, path := range []string{"/gists/new", "/gist/" + id + "/edit", "/gists", "/~alice/gists?tab=starred"} {
81+ if w := get(h, path, ac); w.Code != 200 {
82+ t.Errorf("%s: %d", path, w.Code)
83+ }
84+ }
85+ head := s.gistHead(Gist{ID: id, Owner: alice})
86+ edit := url.Values{"head": {head}, "name": {"hello.go", "gistfile1.txt"}, "orig": {"hello.go", "gistfile1.txt"}, "content": {"package main\n\nfunc main() {}\n", ""}, "description": {"second"}}
87+ if w := post(h, "/gist/"+id+"/edit", edit, bc); w.Code != 403 {
88+ t.Errorf("non-owner edited: %d", w.Code)
89+ }
90+ if w := post(h, "/gist/"+id+"/edit", edit, ac); w.Code != 303 {
91+ t.Fatalf("edit: %d %s", w.Code, w.Body.String())
92+ }
93+ if w := post(h, "/gist/"+id+"/edit", edit, ac); w.Code != 400 {
94+ t.Errorf("stale edit accepted: %d", w.Code)
95+ }
96+ log := get(h, "/gist/"+id+"/revisions", bc).Body.String()
97+ if strings.Count(log, `class="revision"`) != 2 || !strings.Contains(log, "func main() {}") {
98+ t.Error("revisions do not show both commits")
99+ }
100+ if !strings.Contains(get(h, "/gist/"+id+"?rev="+head, bc).Body.String(), "gistfile1.txt") {
101+ t.Error("old revision not viewable")
102+ }
103+
104+ post(h, "/gist/"+id+"/star", nil, bc)
105+ post(h, "/gist/"+id+"/comment", url.Values{"body": {"nice **work**"}}, bc)
106+ if w := post(h, "/gist/"+id+"/fork", nil, bc); w.Code != 303 {
107+ t.Fatalf("fork: %d", w.Code)
108+ }
109+ g, err := s.store.Gist(id)
110+ if err != nil || g.Stars != 1 || g.Forks != 1 || g.Comments != 1 {
111+ t.Errorf("counts: %+v %v", g, err)
112+ }
113+ forks, _ := s.store.GistForks(id)
114+ if len(forks) != 1 || forks[0].Owner.ID != bob.ID {
115+ t.Fatalf("forks: %+v", forks)
116+ }
117+ if !strings.Contains(get(h, "/gist/"+id, ac).Body.String(), "<strong>work</strong>") {
118+ t.Error("comment not rendered")
119+ }
120+
121+ t.Setenv("GITPOCKET_PUSHER", strconv.FormatInt(bob.ID, 10))
122+ update := func(ref, tree string) int {
123+ gd := Git{Dir: gistDir(s.root, "alice", id)}
124+ out, err := gd.runEnv(ident("T", "[email protected]", timeNow()), []byte("x"), "commit-tree", tree, "-p", head)
125+ if err != nil {
126+ t.Fatal(err)
127+ }
128+ var errw bytes.Buffer
129+ return gistPreReceive(s.root, id, strings.NewReader(head+" "+strings.TrimSpace(string(out))+" "+ref+"\n"), &errw)
130+ }
131+ gd := Git{Dir: gistDir(s.root, "alice", id)}
132+ blob, _ := gd.run([]byte("x"), "hash-object", "-w", "--stdin")
133+ flat, _ := gd.run([]byte("100644 blob "+strings.TrimSpace(string(blob))+"\tok.txt\x00"), "mktree", "-z")
134+ nested, _ := gd.run([]byte("040000 tree "+strings.TrimSpace(string(flat))+"\tdir\x00"), "mktree", "-z")
135+ if update("refs/heads/main", strings.TrimSpace(string(flat))) == 0 {
136+ t.Error("non-owner push accepted")
137+ }
138+ t.Setenv("GITPOCKET_PUSHER", strconv.FormatInt(alice.ID, 10))
139+ if update("refs/heads/main", strings.TrimSpace(string(flat))) != 0 {
140+ t.Error("owner push refused")
141+ }
142+ if update("refs/heads/other", strings.TrimSpace(string(flat))) == 0 {
143+ t.Error("push to another branch accepted")
144+ }
145+ if update("refs/heads/main", strings.TrimSpace(string(nested))) == 0 {
146+ t.Error("nested tree accepted")
147+ }
148+
149+ token, err := s.store.CreateToken(alice.ID, "cli")
150+ if err != nil {
151+ t.Fatal(err)
152+ }
153+ body := `{"description":"api","public":true,"files":{"a.md":{"content":"# hi"}}}`
154+ r := httptest.NewRequest("POST", "/api/gists", strings.NewReader(body))
155+ r.Header.Set("Content-Type", "application/json")
156+ r.SetBasicAuth("alice", token)
157+ r.RemoteAddr = "192.0.2.1:1000"
158+ w := httptest.NewRecorder()
159+ h.ServeHTTP(w, r)
160+ var made struct {
161+ ID string
162+ Files map[string]struct{ Content string }
163+ }
164+ if w.Code != 201 || json.Unmarshal(w.Body.Bytes(), &made) != nil || made.Files["a.md"].Content != "# hi" {
165+ t.Fatalf("api create: %d %s", w.Code, w.Body.String())
166+ }
167+ if w := get(h, "/api/gists/"+made.ID, ""); w.Code != 200 || !strings.Contains(w.Body.String(), `"a.md"`) {
168+ t.Errorf("api get: %d", w.Code)
169+ }
170+ r = httptest.NewRequest("POST", "/api/gists?file=x.txt", strings.NewReader("raw upload"))
171+ r.RemoteAddr = "192.0.2.1:1000"
172+ w = httptest.NewRecorder()
173+ h.ServeHTTP(w, r)
174+ if w.Code != 401 {
175+ t.Errorf("anonymous api create: %d", w.Code)
176+ }
177+
178+ if w := post(h, "/gist/"+id+"/delete", nil, bc); w.Code != 403 {
179+ t.Errorf("non-owner deleted: %d", w.Code)
180+ }
181+ if w := post(h, "/gist/"+id+"/delete", nil, ac); w.Code != 303 {
182+ t.Errorf("delete: %d", w.Code)
183+ }
184+ if _, err := s.store.Gist(id); err == nil {
185+ t.Error("gist survived deletion")
186+ }
187+}

forge/gistweb.go added

@@ -0,0 +1,615 @@
1+package forge
2+
3+import (
4+ "encoding/json"
5+ "errors"
6+ "fmt"
7+ "html/template"
8+ "io"
9+ "mime"
10+ "net/http"
11+ "net/url"
12+ "os"
13+ "path/filepath"
14+ "regexp"
15+ "sort"
16+ "strconv"
17+ "strings"
18+ "time"
19+)
20+
21+var fullOID = regexp.MustCompile(`^[0-9a-f]{40}$`)
22+
23+func (s *Server) gistIndex(w http.ResponseWriter, r *http.Request) {
24+ user := s.current(r)
25+ gists, err := s.store.Gists(0, false, 50)
26+ if err != nil {
27+ s.fail(w, r, 500, err.Error())
28+ return
29+ }
30+ s.render(w, r, "gists", page{Title: "Gists", User: user, Data: map[string]any{"Items": s.gistItems(gists)}})
31+}
32+
33+func (s *Server) userGists(w http.ResponseWriter, r *http.Request, owner User, user *User) {
34+ own := user != nil && user.ID == owner.ID
35+ gists, err := s.store.Gists(owner.ID, own, 200)
36+ if err != nil {
37+ s.fail(w, r, 500, err.Error())
38+ return
39+ }
40+ data := map[string]any{"Owner": owner, "Items": s.gistItems(gists), "Own": own, "Count": len(gists)}
41+ if r.URL.Query().Get("tab") == "starred" {
42+ starred, _ := s.store.StarredGists(owner.ID)
43+ data["Items"], data["Starred"] = s.gistItems(starred), true
44+ }
45+ s.render(w, r, "gists", page{Title: "~" + owner.Name + " gists", User: user, Data: data})
46+}
47+
48+func formFiles(r *http.Request) []gistFile {
49+ names, contents, origs := r.PostForm["name"], r.PostForm["content"], r.PostForm["orig"]
50+ var files []gistFile
51+ for i := range min(len(contents), gistFormFiles) {
52+ f := gistFile{Content: strings.ReplaceAll(contents[i], "\r\n", "\n")}
53+ if i < len(names) {
54+ f.Name = strings.TrimSpace(names[i])
55+ }
56+ if i < len(origs) {
57+ f.Orig = origs[i]
58+ }
59+ files = append(files, f)
60+ }
61+ return files
62+}
63+
64+func (s *Server) newGistForm(w http.ResponseWriter, r *http.Request) {
65+ u := s.require(w, r)
66+ if u == nil {
67+ return
68+ }
69+ s.render(w, r, "gistform", page{Title: "New gist", User: u, Data: map[string]any{"Files": []gistFile{{}}, "Action": "/gists"}})
70+}
71+
72+func (s *Server) postGist(w http.ResponseWriter, r *http.Request) {
73+ u := s.require(w, r)
74+ if u == nil {
75+ return
76+ }
77+ r.Body = http.MaxBytesReader(w, r.Body, 8<<20)
78+ if err := r.ParseForm(); err != nil {
79+ s.fail(w, r, 400, "The gist is too large")
80+ return
81+ }
82+ files := formFiles(r)
83+ data := map[string]any{"Files": files, "Action": "/gists", "Description": r.FormValue("description"), "Secret": r.FormValue("visibility") == "secret"}
84+ if r.FormValue("action") == "add" {
85+ data["Files"] = append(files, gistFile{})
86+ s.render(w, r, "gistform", page{Title: "New gist", User: u, Data: data})
87+ return
88+ }
89+ gist, err := s.createGist(*u, r.FormValue("description"), r.FormValue("visibility") == "secret", files)
90+ if err != nil {
91+ w.WriteHeader(400)
92+ s.render(w, r, "gistform", page{Title: "New gist", User: u, Error: err.Error(), Data: data})
93+ return
94+ }
95+ http.Redirect(w, r, "/gist/"+gist.ID, http.StatusSeeOther)
96+}
97+
98+func (s *Server) gistRoute(w http.ResponseWriter, r *http.Request) {
99+ id, sub, _ := strings.Cut(strings.TrimPrefix(r.URL.Path, "/gist/"), "/")
100+ id = strings.TrimSuffix(id, ".git")
101+ if !gistID.MatchString(id) {
102+ s.fail(w, r, 404, "No such gist")
103+ return
104+ }
105+ gist, err := s.store.Gist(id)
106+ if err != nil {
107+ if isSmart(r, sub) {
108+ http.Error(w, "gist not found", 404)
109+ return
110+ }
111+ s.fail(w, r, 404, "No such gist")
112+ return
113+ }
114+ if isSmart(r, sub) {
115+ s.gistSmart(w, r, gist, sub)
116+ return
117+ }
118+ user := s.current(r)
119+ back := "/gist/" + gist.ID
120+ switch {
121+ case sub == "" && r.Method == "GET":
122+ s.gistPage(w, r, gist, user)
123+ case sub == "revisions" && r.Method == "GET":
124+ s.gistRevisions(w, r, gist, user)
125+ case sub == "edit":
126+ s.editGist(w, r, gist, user)
127+ case strings.HasPrefix(sub, "raw/") && r.Method == "GET":
128+ http.Redirect(w, r, "https://"+s.rawHost()+"/raw/"+gist.ID+"/"+escapePath(strings.TrimPrefix(sub, "raw/")), http.StatusFound)
129+ case r.Method != "POST":
130+ s.fail(w, r, 404, "Page not found")
131+ case user == nil:
132+ http.Redirect(w, r, "/login", http.StatusSeeOther)
133+ case sub == "star":
134+ s.store.StarGist(user.ID, gist.ID, !s.store.GistStarred(user.ID, gist.ID))
135+ http.Redirect(w, r, back, http.StatusSeeOther)
136+ case sub == "fork":
137+ if user.ID == gist.Owner.ID {
138+ s.fail(w, r, 400, "You already own this gist")
139+ return
140+ }
141+ fork, err := s.forkGist(*user, gist)
142+ if err != nil {
143+ s.fail(w, r, 400, err.Error())
144+ return
145+ }
146+ http.Redirect(w, r, "/gist/"+fork.ID, http.StatusSeeOther)
147+ case sub == "delete":
148+ if user.ID != gist.Owner.ID && !s.store.IsAdmin(user.ID) {
149+ s.fail(w, r, 403, "Only the owner can delete this gist")
150+ return
151+ }
152+ s.store.DeleteGist(gist.ID)
153+ os.RemoveAll(s.gistGit(gist).Dir)
154+ s.store.CloseReports("gist", gist.Owner.Name+"/"+gist.ID, "resolved", user.ID)
155+ http.Redirect(w, r, "/~"+gist.Owner.Name+"/gists", http.StatusSeeOther)
156+ case sub == "comment":
157+ if s.store.Blocked(gist.Owner.ID, user.ID) {
158+ s.fail(w, r, 403, "You cannot comment here")
159+ return
160+ }
161+ if err := s.store.AddGistComment(gist.ID, user.ID, r.FormValue("body")); err != nil {
162+ http.Redirect(w, r, back+"?error="+url.QueryEscape(err.Error())+"#comments", http.StatusSeeOther)
163+ return
164+ }
165+ http.Redirect(w, r, back+"#comments", http.StatusSeeOther)
166+ case strings.HasPrefix(sub, "comment/") && strings.HasSuffix(sub, "/delete"):
167+ cid, err := strconv.ParseInt(strings.TrimSuffix(strings.TrimPrefix(sub, "comment/"), "/delete"), 10, 64)
168+ author, ok := s.store.GistCommentAuthor(gist.ID, cid)
169+ if err != nil || !ok {
170+ s.fail(w, r, 404, "No such comment")
171+ return
172+ }
173+ if author != user.ID && gist.Owner.ID != user.ID && !s.store.IsAdmin(user.ID) {
174+ s.fail(w, r, 403, "You cannot delete that comment")
175+ return
176+ }
177+ s.store.DeleteGistComment(cid)
178+ http.Redirect(w, r, back+"#comments", http.StatusSeeOther)
179+ default:
180+ s.fail(w, r, 404, "Page not found")
181+ }
182+}
183+
184+func (s *Server) gistPage(w http.ResponseWriter, r *http.Request, gist Gist, user *User) {
185+ head := s.gistHead(gist)
186+ commit := head
187+ if rev := r.URL.Query().Get("rev"); fullOID.MatchString(rev) {
188+ if c, err := s.gistGit(gist).Resolve(rev + "^{commit}"); err == nil {
189+ commit = c
190+ }
191+ }
192+ views := s.gistViews(gist, commit, 0)
193+ var files []string
194+ for _, v := range views {
195+ files = append(files, v.Name)
196+ }
197+ comments := s.store.GistComments(gist.ID)
198+ rendered := make([]map[string]any, len(comments))
199+ math := false
200+ for i, c := range comments {
201+ body := s.renderMarkdown([]byte(c.Body))
202+ math = math || hasMath(body)
203+ rendered[i] = map[string]any{"Comment": c, "Body": body}
204+ }
205+ for _, v := range views {
206+ math = math || hasMath(v.Markdown)
207+ }
208+ data := map[string]any{
209+ "Gist": gist, "Files": views, "Title": gist.Title(files), "Commit": commit, "Head": head, "Old": commit != head,
210+ "Comments": rendered, "Owner": user != nil && user.ID == gist.Owner.ID,
211+ "Admin": user != nil && s.store.IsAdmin(user.ID), "Starred": user != nil && s.store.GistStarred(user.ID, gist.ID),
212+ "Clone": "https://" + s.host + "/gist/" + gist.ID + ".git", "SSH": s.sshUser + "@" + s.sshHost + ":gist/" + gist.ID + ".git",
213+ "Embed": `<iframe src="https://` + s.rawHost() + `/embed/` + gist.ID + `" style="width:100%;height:24em;border:0" loading="lazy"></iframe>`,
214+ }
215+ if c, err := s.gistGit(gist).Commit(commit); err == nil {
216+ data["Last"] = c
217+ }
218+ if gist.Parent != "" {
219+ if p, err := s.store.Gist(gist.Parent); err == nil && !p.Secret {
220+ data["Parent"] = p
221+ }
222+ }
223+ if forks, _ := s.store.GistForks(gist.ID); len(forks) > 0 {
224+ data["Forks"] = forks
225+ }
226+ s.render(w, r, "gist", page{Title: gist.Title(files), User: user, Error: r.URL.Query().Get("error"), Math: math, Data: data})
227+}
228+
229+func (s *Server) gistRevisions(w http.ResponseWriter, r *http.Request, gist Gist, user *User) {
230+ g := s.gistGit(gist)
231+ commits, _ := g.Log(s.gistHead(gist), 30, "")
232+ type revision struct {
233+ Commit Commit
234+ Files []File
235+ }
236+ revs := make([]revision, 0, len(commits))
237+ for _, c := range commits {
238+ files, _ := g.Diff(c.Hash)
239+ revs = append(revs, revision{Commit: c, Files: files})
240+ }
241+ s.render(w, r, "gistlog", page{Title: "Revisions of gist " + gist.ID[:8], User: user, Data: map[string]any{"Gist": gist, "Revisions": revs}})
242+}
243+
244+func (s *Server) editGist(w http.ResponseWriter, r *http.Request, gist Gist, user *User) {
245+ if user == nil {
246+ http.Redirect(w, r, "/login", http.StatusSeeOther)
247+ return
248+ }
249+ if user.ID != gist.Owner.ID {
250+ s.fail(w, r, 403, "Only the owner can edit this gist")
251+ return
252+ }
253+ g := s.gistGit(gist)
254+ head := s.gistHead(gist)
255+ if r.Method == "POST" {
256+ r.Body = http.MaxBytesReader(w, r.Body, 8<<20)
257+ if err := r.ParseForm(); err != nil {
258+ s.fail(w, r, 400, "The gist is too large")
259+ return
260+ }
261+ head = r.FormValue("head")
262+ }
263+ entries, err := g.gistTree(head)
264+ if err != nil {
265+ s.fail(w, r, 409, "The gist changed while you were editing it, reload and try again")
266+ return
267+ }
268+ var files []gistFile
269+ var keep []gistEntry
270+ for _, e := range entries {
271+ if e.Size <= gistWebFile {
272+ if content, err := g.run(nil, "cat-file", "blob", e.OID); err == nil && !isBinary(content) {
273+ files = append(files, gistFile{Orig: e.Name, Name: e.Name, Content: string(content)})
274+ continue
275+ }
276+ }
277+ keep = append(keep, e)
278+ }
279+ data := map[string]any{"Gist": gist, "Files": files, "Keep": keep, "Head": head, "Action": "/gist/" + gist.ID + "/edit", "Description": gist.Description, "Secret": gist.Secret}
280+ if r.Method != "POST" {
281+ s.render(w, r, "gistform", page{Title: "Edit gist", User: user, Data: data})
282+ return
283+ }
284+ posted := formFiles(r)
285+ secret := r.FormValue("visibility") == "secret"
286+ data["Files"], data["Description"], data["Secret"] = posted, r.FormValue("description"), secret
287+ if r.FormValue("action") == "add" {
288+ data["Files"] = append(posted, gistFile{})
289+ s.render(w, r, "gistform", page{Title: "Edit gist", User: user, Data: data})
290+ return
291+ }
292+ retry := func(msg string) {
293+ w.WriteHeader(400)
294+ s.render(w, r, "gistform", page{Title: "Edit gist", User: user, Error: msg, Data: data})
295+ }
296+ description, err := cleanDescription(r.FormValue("description"))
297+ if err != nil {
298+ retry(err.Error())
299+ return
300+ }
301+ if over, used, quota := overQuota(s.root, user.Name); over {
302+ retry(quotaMessage(used, quota))
303+ return
304+ }
305+ if _, err := s.writeGist(g, head, posted, keep, *user, "Edit gist"); err != nil && !errors.Is(err, errNoChange) {
306+ retry(err.Error())
307+ return
308+ }
309+ s.store.UpdateGist(gist.ID, description, secret)
310+ http.Redirect(w, r, "/gist/"+gist.ID, http.StatusSeeOther)
311+}
312+
313+func (s *Server) gistSmart(w http.ResponseWriter, r *http.Request, gist Gist, rest string) {
314+ service := r.URL.Query().Get("service")
315+ if rest != "info/refs" {
316+ service = rest
317+ }
318+ if service != "git-upload-pack" && service != "git-receive-pack" {
319+ http.Error(w, "unsupported service", 403)
320+ return
321+ }
322+ var recv, env []string
323+ limit := int64(0)
324+ if service == "git-receive-pack" {
325+ ip := clientIP(r)
326+ if s.limiter.blocked(ip) {
327+ http.Error(w, "too many failed attempts", 429)
328+ return
329+ }
330+ u, ok := s.basicUser(r)
331+ if !ok || u.ID != gist.Owner.ID {
332+ if _, _, given := r.BasicAuth(); given {
333+ s.limiter.fail(ip)
334+ }
335+ w.Header().Set("WWW-Authenticate", `Basic realm="`+s.host+`"`)
336+ http.Error(w, "authentication required", 401)
337+ return
338+ }
339+ if !hooksReady(s.root) {
340+ http.Error(w, "pushes are unavailable right now", 503)
341+ return
342+ }
343+ over, used, quota := overQuota(s.root, gist.Owner.Name)
344+ if over {
345+ http.Error(w, quotaMessage(used, quota), 403)
346+ return
347+ }
348+ if quota > 0 {
349+ limit = quota - used + 1<<20
350+ }
351+ recv, env = gistReceiveArgs(s.root, gist.Owner.Name), gistEnv(gist, u)
352+ }
353+ s.gitService(w, r, s.gistGit(gist).Dir, service, rest, recv, env, limit)
354+ if service == "git-receive-pack" && rest != "info/refs" {
355+ s.store.TouchGist(gist.ID)
356+ }
357+}
358+
359+const rawPolicy = "default-src 'none'; img-src data:; style-src 'unsafe-inline'; sandbox"
360+
361+const embedPolicy = "default-src 'none'; style-src 'unsafe-inline'; img-src https: data:; base-uri 'none'; form-action 'none'; frame-ancestors *"
362+
363+func (s *Server) serveGistRaw(w http.ResponseWriter, r *http.Request) {
364+ h := w.Header()
365+ h.Set("X-Content-Type-Options", "nosniff")
366+ h.Set("Referrer-Policy", "no-referrer")
367+ if r.Method != "GET" && r.Method != "HEAD" {
368+ h.Set("Allow", "GET, HEAD")
369+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
370+ return
371+ }
372+ if !s.requests.allow(clientIP(r)) {
373+ h.Set("Retry-After", "10")
374+ http.Error(w, "too many requests, slow down", http.StatusTooManyRequests)
375+ return
376+ }
377+ notFound := func() { http.Error(w, "not found", http.StatusNotFound) }
378+ kind, rest, _ := strings.Cut(strings.TrimPrefix(r.URL.Path, "/"), "/")
379+ id, rest, _ := strings.Cut(rest, "/")
380+ if kind == "" {
381+ http.Redirect(w, r, "https://"+s.host+"/gists", http.StatusFound)
382+ return
383+ }
384+ if !gistID.MatchString(id) {
385+ notFound()
386+ return
387+ }
388+ gist, err := s.store.Gist(id)
389+ if err != nil {
390+ notFound()
391+ return
392+ }
393+ g := s.gistGit(gist)
394+ switch kind {
395+ case "embed":
396+ if rest != "" {
397+ notFound()
398+ return
399+ }
400+ s.gistEmbed(w, r, gist)
401+ case "raw":
402+ commit, file := s.gistHead(gist), rest
403+ pinned := false
404+ if rev, name, ok := strings.Cut(rest, "/"); ok && fullOID.MatchString(rev) {
405+ c, err := g.Resolve(rev + "^{commit}")
406+ if err != nil {
407+ notFound()
408+ return
409+ }
410+ commit, file, pinned = c, name, true
411+ }
412+ if commit == "" || checkGistName(file) != nil {
413+ notFound()
414+ return
415+ }
416+ mode, oid, ok := g.pageEntry(commit, file)
417+ if !ok || (mode != "100644" && mode != "100755") {
418+ notFound()
419+ return
420+ }
421+ data, err := g.run(nil, "cat-file", "blob", oid)
422+ if err != nil || len(data) > gistPushFile {
423+ notFound()
424+ return
425+ }
426+ ctype := "text/plain; charset=utf-8"
427+ if kind := mediaTypes[strings.ToLower(filepath.Ext(file))]; strings.HasPrefix(kind, "image/") {
428+ ctype = kind
429+ }
430+ h.Set("Content-Type", ctype)
431+ h.Set("Content-Security-Policy", rawPolicy)
432+ h.Set("Access-Control-Allow-Origin", "*")
433+ h.Set("Cross-Origin-Resource-Policy", "cross-origin")
434+ h.Set("Content-Disposition", mime.FormatMediaType("inline", map[string]string{"filename": file}))
435+ if pinned {
436+ h.Set("Cache-Control", "public, max-age=31536000, immutable")
437+ } else {
438+ h.Set("Cache-Control", "public, max-age=60")
439+ }
440+ h.Set("Content-Length", strconv.Itoa(len(data)))
441+ w.Write(data)
442+ default:
443+ notFound()
444+ }
445+}
446+
447+var embedTemplate = template.Must(template.New("embed").Funcs(template.FuncMap{
448+ "lines": func(b []byte) []string { return strings.Split(strings.TrimSuffix(string(b), "\n"), "\n") },
449+ "inc": func(i int) int { return i + 1 },
450+}).Parse(`<!doctype html>
451+<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>{{.Title}}</title>
452+<style>
453+body{margin:0;font:14px/1.5 system-ui,sans-serif;color:#1f2328;background:#fff}
454+.file{border:1px solid #d0d7de;border-radius:6px;margin:0 0 12px;overflow:hidden}
455+.head{display:flex;justify-content:space-between;gap:8px;padding:6px 10px;background:#f6f8fa;border-bottom:1px solid #d0d7de;font-size:12px}
456+.head a{color:#0969da;text-decoration:none}
457+.body{overflow:auto;max-height:40em}
458+pre,code,table.blob{font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace;margin:0}
459+.chroma{padding:8px 0}
460+.chroma .lntd:first-child{padding:0 8px;color:#8c959f;user-select:none}
461+.chroma .lnlinks{color:#8c959f;text-decoration:none}
462+table.blob td.n{padding:0 8px;color:#8c959f;text-align:right;user-select:none}
463+table.blob td.l{white-space:pre;padding-right:8px}
464+.readme{padding:8px 16px}
465+img{max-width:100%}
466+.note{padding:8px 10px;color:#59636e}
467+{{.CSS}}
468+</style></head><body>
469+{{range .Files}}<div class="file"><div class="head"><span>{{.Name}}</span><span><a href="{{.Raw}}" target="_blank" rel="noopener">raw</a> · <a href="{{$.Link}}" target="_blank" rel="noopener">hosted on {{$.Host}}</a></span></div><div class="body">
470+{{if .Image}}<img src="{{.Raw}}" alt="{{.Name}}">{{else if .Binary}}<p class="note">Binary file.</p>{{else if .Large}}<p class="note">File too large to display.</p>{{else if .Markdown}}<div class="readme">{{.Markdown}}</div>{{else if .Highlighted}}{{.Highlighted}}{{else}}<table class="blob">{{range $i, $l := lines .Content}}<tr><td class="n">{{inc $i}}</td><td class="l">{{$l}}</td></tr>{{end}}</table>{{end}}
471+</div></div>{{end}}
472+</body></html>
473+`))
474+
475+func (s *Server) gistEmbed(w http.ResponseWriter, r *http.Request, gist Gist) {
476+ head := s.gistHead(gist)
477+ if head == "" {
478+ http.Error(w, "not found", http.StatusNotFound)
479+ return
480+ }
481+ views := s.gistViews(gist, head, 0)
482+ var names []string
483+ for _, v := range views {
484+ names = append(names, v.Name)
485+ }
486+ h := w.Header()
487+ h.Set("Content-Type", "text/html; charset=utf-8")
488+ h.Set("Content-Security-Policy", embedPolicy)
489+ h.Set("Cache-Control", "public, max-age=60")
490+ embedTemplate.Execute(w, map[string]any{
491+ "Title": gist.Title(names), "Files": views, "Host": s.host, "Link": "https://" + s.host + "/gist/" + gist.ID,
492+ "CSS": template.CSS(highlightCSS()),
493+ })
494+}
495+
496+type apiGistFile struct {
497+ Filename string `json:"filename"`
498+ Size int64 `json:"size"`
499+ RawURL string `json:"raw_url"`
500+ Content string `json:"content,omitempty"`
501+ Truncated bool `json:"truncated"`
502+}
503+
504+type apiGist struct {
505+ ID string `json:"id"`
506+ Description string `json:"description"`
507+ Public bool `json:"public"`
508+ Owner string `json:"owner"`
509+ HTMLURL string `json:"html_url"`
510+ GitPullURL string `json:"git_pull_url"`
511+ EmbedURL string `json:"embed_url"`
512+ Created string `json:"created_at"`
513+ Updated string `json:"updated_at"`
514+ Files map[string]apiGistFile `json:"files"`
515+}
516+
517+func (s *Server) apiView(gist Gist) apiGist {
518+ out := apiGist{ID: gist.ID, Description: gist.Description, Public: !gist.Secret, Owner: gist.Owner.Name,
519+ HTMLURL: "https://" + s.host + "/gist/" + gist.ID, GitPullURL: "https://" + s.host + "/gist/" + gist.ID + ".git",
520+ EmbedURL: "https://" + s.rawHost() + "/embed/" + gist.ID,
521+ Created: gist.Created.UTC().Format(time.RFC3339), Updated: gist.Updated.UTC().Format(time.RFC3339), Files: map[string]apiGistFile{}}
522+ g := s.gistGit(gist)
523+ head := s.gistHead(gist)
524+ entries, _ := g.gistTree(head)
525+ for _, e := range entries {
526+ f := apiGistFile{Filename: e.Name, Size: e.Size, RawURL: s.rawURL(gist, head, e.Name)}
527+ if e.Size > gistWebFile {
528+ f.Truncated = true
529+ } else if data, err := g.run(nil, "cat-file", "blob", e.OID); err == nil && !isBinary(data) {
530+ f.Content = string(data)
531+ }
532+ out.Files[e.Name] = f
533+ }
534+ return out
535+}
536+
537+func writeJSON(w http.ResponseWriter, code int, v any) {
538+ w.Header().Set("Content-Type", "application/json")
539+ w.WriteHeader(code)
540+ enc := json.NewEncoder(w)
541+ enc.SetIndent("", " ")
542+ enc.Encode(v)
543+}
544+
545+func (s *Server) apiGist(w http.ResponseWriter, r *http.Request) {
546+ id := r.PathValue("id")
547+ gist, err := s.store.Gist(id)
548+ if !gistID.MatchString(id) || err != nil {
549+ writeJSON(w, 404, map[string]string{"message": "gist not found"})
550+ return
551+ }
552+ w.Header().Set("Access-Control-Allow-Origin", "*")
553+ writeJSON(w, 200, s.apiView(gist))
554+}
555+
556+func (s *Server) apiCreateGist(w http.ResponseWriter, r *http.Request) {
557+ ip := clientIP(r)
558+ if s.limiter.blocked(ip) {
559+ writeJSON(w, 429, map[string]string{"message": "too many failed attempts"})
560+ return
561+ }
562+ u, ok := s.basicUser(r)
563+ if !ok {
564+ if _, _, given := r.BasicAuth(); given {
565+ s.limiter.fail(ip)
566+ }
567+ w.Header().Set("WWW-Authenticate", `Basic realm="`+s.host+`"`)
568+ writeJSON(w, 401, map[string]string{"message": "authenticate with your name and an access token"})
569+ return
570+ }
571+ body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 8<<20))
572+ if err != nil {
573+ writeJSON(w, 413, map[string]string{"message": "request too large"})
574+ return
575+ }
576+ isJSON := strings.HasPrefix(r.Header.Get("Content-Type"), "application/json")
577+ var description string
578+ var public bool
579+ var files []gistFile
580+ if isJSON {
581+ var req struct {
582+ Description string `json:"description"`
583+ Public bool `json:"public"`
584+ Files map[string]struct {
585+ Content string `json:"content"`
586+ } `json:"files"`
587+ }
588+ if err := json.Unmarshal(body, &req); err != nil {
589+ writeJSON(w, 400, map[string]string{"message": "invalid JSON: " + err.Error()})
590+ return
591+ }
592+ description, public = req.Description, req.Public
593+ for name, f := range req.Files {
594+ files = append(files, gistFile{Name: name, Content: f.Content})
595+ }
596+ sort.Slice(files, func(i, j int) bool { return files[i].Name < files[j].Name })
597+ } else {
598+ q := r.URL.Query()
599+ description = q.Get("description")
600+ public = q.Get("public") == "1" || q.Get("public") == "true"
601+ files = []gistFile{{Name: q.Get("file"), Content: string(body)}}
602+ }
603+ gist, err := s.createGist(u, description, !public, files)
604+ if err != nil {
605+ writeJSON(w, 422, map[string]string{"message": err.Error()})
606+ return
607+ }
608+ if !isJSON {
609+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
610+ w.WriteHeader(201)
611+ fmt.Fprintln(w, "https://"+s.host+"/gist/"+gist.ID)
612+ return
613+ }
614+ writeJSON(w, 201, s.apiView(gist))
615+}

forge/moderation.go modified

@@ -43,7 +43,7 @@ const (
4343 maxReason = 2000
4444 )
4545
46-var reportKinds = map[string]string{"user": "account", "repo": "repository", "issue": "issue", "pull": "pull request"}
46+var reportKinds = map[string]string{"user": "account", "repo": "repository", "issue": "issue", "pull": "pull request", "gist": "gist"}
4747
4848 type Report struct {
4949 ID int64
@@ -61,6 +61,9 @@ func (r Report) Link() string {
6161 return "/~" + r.Target
6262 case "repo":
6363 return "/~" + r.Target
64+ case "gist":
65+ _, id, _ := strings.Cut(r.Target, "/")
66+ return "/gist/" + id
6467 case "issue", "pull":
6568 repo, num, _ := strings.Cut(r.Target, "#")
6669 section := map[string]string{"issue": "issues", "pull": "pulls"}[r.Kind]
@@ -232,6 +235,9 @@ func (s *Server) reportTarget(kind, target string, viewer *User) (string, bool)
232235 case "user":
233236 u, err := s.store.UserByName(target)
234237 return u.Name, err == nil
238+ case "gist":
239+ g, err := s.store.Gist(target)
240+ return g.Owner.Name + "/" + g.ID, err == nil
235241 case "repo", "issue", "pull":
236242 path, num, hasNum := strings.Cut(target, "#")
237243 owner, name, ok := strings.Cut(path, "/")

forge/shell.go modified

@@ -46,6 +46,9 @@ func shell(root, name string) int {
4646 arg = strings.Trim(strings.TrimSpace(arg), "'\"")
4747 arg = strings.TrimPrefix(strings.TrimPrefix(arg, "/"), "~")
4848 arg = strings.TrimSuffix(arg, ".git")
49+ if id, ok := strings.CutPrefix(arg, "gist/"); ok {
50+ return gistShell(root, name, verb, id)
51+ }
4952 owner, repoName, ok := strings.Cut(arg, "/")
5053 if !ok || !validName(owner) || !validName(repoName) {
5154 return fail("repository path must be ~owner/repo")
@@ -98,3 +101,49 @@ func shell(root, name string) int {
98101 err = syscall.Exec(git, append(argv, strings.TrimPrefix(verb, "git-"), dir), env)
99102 return fail(err.Error())
100103 }
104+
105+func gistShell(root, name, verb, id string) int {
106+ fail := func(msg string) int {
107+ fmt.Fprintln(os.Stderr, msg)
108+ return 1
109+ }
110+ if !gistID.MatchString(id) || verb == "git-upload-archive" {
111+ return fail("gist path must be gist/<id>")
112+ }
113+ store, err := openStore(filepath.Join(root, "gitpocket.db"), true)
114+ if err != nil {
115+ return fail("database unavailable")
116+ }
117+ user, err := store.UserByName(name)
118+ if err != nil {
119+ return fail("unknown user")
120+ }
121+ gist, err := store.Gist(id)
122+ if err != nil {
123+ return fail("gist not found")
124+ }
125+ git, err := exec.LookPath("git")
126+ if err != nil {
127+ return fail("git not installed")
128+ }
129+ env := []string{"PATH=" + os.Getenv("PATH"), "HOME=" + os.Getenv("HOME")}
130+ if p := os.Getenv("GIT_PROTOCOL"); p != "" {
131+ env = append(env, "GIT_PROTOCOL="+p)
132+ }
133+ argv := []string{"git"}
134+ if verb == "git-receive-pack" {
135+ if gist.Owner.ID != user.ID {
136+ return fail("only the owner can push to this gist")
137+ }
138+ if !hooksReady(root) {
139+ return fail("pushes are unavailable right now")
140+ }
141+ if over, used, quota := overQuota(root, gist.Owner.Name); over {
142+ return fail(quotaMessage(used, quota))
143+ }
144+ argv = append(argv, gistReceiveArgs(root, gist.Owner.Name)...)
145+ env = append(env, gistEnv(gist, user)...)
146+ }
147+ err = syscall.Exec(git, append(argv, strings.TrimPrefix(verb, "git-"), gistDir(root, gist.Owner.Name, gist.ID)), env)
148+ return fail(err.Error())
149+}

forge/store.go modified

@@ -279,7 +279,7 @@ func openStore(path string, readonly bool) (*Store, error) {
279279 if _, err := db.Exec(schema); err != nil {
280280 return nil, err
281281 }
282- for _, ddl := range []string{pullSchema, collabSchema, extraSchema, adminSchema, inboxSchema, followSchema, renameSchema, moderationSchema, mirrorSchema, domainSchema} {
282+ for _, ddl := range []string{pullSchema, collabSchema, extraSchema, adminSchema, inboxSchema, followSchema, renameSchema, moderationSchema, mirrorSchema, domainSchema, gistSchema} {
283283 if _, err := db.Exec(ddl); err != nil {
284284 return nil, err
285285 }
@@ -308,7 +308,7 @@ func validName(name string) bool {
308308 return nameRe.MatchString(name) && !strings.HasSuffix(name, ".git")
309309 }
310310
311-var reservedUsers = map[string]bool{"www": true, "mail": true, "git": true, "api": true, "static": true, "pages": true, "ssh": true, "smtp": true, "imap": true, "pop": true, "mx": true, "ns1": true, "ns2": true, "autoconfig": true, "autodiscover": true, "status": true, "docs": true, "help": true, "support": true, "security": true, "abuse": true, "postmaster": true, "webmaster": true, "hostmaster": true, "root": true, "login": true, "cdn": true}
311+var reservedUsers = map[string]bool{"www": true, "mail": true, "git": true, "api": true, "static": true, "pages": true, "ssh": true, "smtp": true, "imap": true, "pop": true, "mx": true, "ns1": true, "ns2": true, "autoconfig": true, "autodiscover": true, "status": true, "docs": true, "help": true, "support": true, "security": true, "abuse": true, "postmaster": true, "webmaster": true, "hostmaster": true, "root": true, "login": true, "cdn": true, "gist": true, "gists": true, "raw": true}
312312
313313 func checkName(name string) error {
314314 if !validName(name) {
@@ -1137,7 +1137,7 @@ func validEmail(email string) bool {
11371137 const maxPins = 6
11381138
11391139 func reservedRepo(name string) bool {
1140- return name == "avatar" || name == "stars" || name == "feed" || name == "repos" || name == "follow" || name == "followers" || name == "following" || name == "import" || name == "claim"
1140+ return name == "avatar" || name == "stars" || name == "feed" || name == "repos" || name == "follow" || name == "followers" || name == "following" || name == "import" || name == "claim" || name == "gists"
11411141 }
11421142
11431143 func (s *Store) Pins(owner int64) map[int64]int {

forge/web.go modified

@@ -303,10 +303,20 @@ func (s *Server) handler() http.Handler {
303303 mux.HandleFunc("POST /report", s.report)
304304 mux.HandleFunc("POST /block", s.block)
305305 mux.HandleFunc("POST /settings/pages", s.setDomain)
306+ mux.HandleFunc("GET /gists", s.gistIndex)
307+ mux.HandleFunc("GET /gists/new", s.newGistForm)
308+ mux.HandleFunc("POST /gists", s.postGist)
309+ mux.HandleFunc("/gist/", s.gistRoute)
310+ mux.HandleFunc("POST /api/gists", s.apiCreateGist)
311+ mux.HandleFunc("GET /api/gists/{id}", s.apiGist)
306312 mux.HandleFunc("/", s.route)
307313 cop := http.NewCrossOriginProtection()
308314 site := secure(s.throttle(cop.Handler(mux)))
309315 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
316+ if bareHost(r.Host) == s.rawHost() {
317+ s.serveGistRaw(w, r)
318+ return
319+ }
310320 if host := bareHost(r.Host); host != s.host && !strings.HasSuffix(host, "."+s.host) {
311321 if owner, ok := s.store.DomainOwner(host); ok {
312322 s.servePages(w, r, owner.Name)
@@ -1020,6 +1030,10 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) {
10201030 s.repos(w, r, owner, user)
10211031 return
10221032 }
1033+ if name == "gists" && len(parts) == 2 && r.Method == "GET" && !s.store.IsOrg(owner.ID) {
1034+ s.userGists(w, r, owner, user)
1035+ return
1036+ }
10231037 if name == "stars" && len(parts) == 2 && r.Method == "GET" {
10241038 starred, _ := s.store.StarredRepos(owner.ID)
10251039 s.render(w, r, "stars", page{Title: "~" + owner.Name + " stars", User: user, Data: map[string]any{"Owner": owner, "Starred": starred, "Count": len(starred)}})

web/static/style.css modified

@@ -559,3 +559,28 @@ fieldset.links legend{padding:0;margin-bottom:.25rem}
559559 .modlinks{margin:.6em 0;text-align:center}
560560 button.link{background:none;border:0;padding:0;color:inherit;font:inherit;cursor:pointer;text-decoration:underline}
561561 pre.reason{white-space:pre-wrap;word-break:break-word}
562+.gisthead{display:flex;flex-wrap:wrap;align-items:flex-start;justify-content:space-between;gap:.8rem;margin-bottom:.6rem}
563+.gisthead h2{font-size:1.3rem;overflow-wrap:anywhere}
564+.gistactions{display:flex;flex-wrap:wrap;gap:.4rem}
565+.gistactions form.inline{margin:0}
566+nav.gisttabs,nav.gistfilter{border-bottom:1px solid var(--bar);margin-bottom:.8rem}
567+.clonebox{margin-bottom:.8rem}
568+.clonebox summary{cursor:pointer;color:var(--muted)}
569+.clonebox dl.meta{margin-top:.5rem}
570+.gistfile{border:1px solid var(--line);margin-bottom:1rem}
571+.gistfilebar{display:flex;align-items:center;gap:.6rem;padding:.3rem .7rem;background:var(--raised);border-bottom:1px solid var(--line);overflow-wrap:anywhere}
572+.gistfile .code,.gistpreview .code{margin:0;min-height:0}
573+.gistfile .code td.lntd,.gistpreview .code td.lntd{white-space:normal}
574+.gistfile .code .lntd:first-child,.gistpreview .code .lntd:first-child{min-height:0}
575+.gistfile .code .lntd:last-child,.gistpreview .code .lntd:last-child{min-width:0;border-right:0}
576+.gistnote{padding:.6rem .7rem;margin:0}
577+.gistmd{padding:.6rem 1rem}
578+.gistitem h4{gap:.3rem}
579+.gistitem .gistpreview{display:block;border:1px solid var(--line);margin-top:.4rem;max-height:14rem;overflow:hidden;color:inherit}
580+.gistitem .gistpreview:hover{text-decoration:none;border-color:var(--focus)}
581+.gistedit{display:flex;flex-direction:column;gap:.3rem;border:1px solid var(--line);padding:.5rem;background:var(--raised)}
582+.gistedit textarea{tab-size:4;white-space:pre;overflow-wrap:normal;overflow-x:auto}
583+.gistopts{display:flex;flex-wrap:wrap;gap:1rem}
584+.gistforks{list-style:none;padding:0;margin:0 0 1rem}
585+.revision{margin-bottom:1.2rem}
586+h4.rule{border-bottom:1px solid var(--bar);padding-bottom:.2rem;margin:1.2rem 0 .6rem}

web/templates/follows.tmpl modified

@@ -2,7 +2,7 @@
22 {{$o := .Data.Owner}}
33 <div class="userhead">
44 <h2>~{{$o.Name}}</h2>
5-<nav class="tabs small"><a href="/~{{$o.Name}}">about</a><a href="/~{{$o.Name}}/repos">repos</a>{{if not .Data.Org}}<a href="/~{{$o.Name}}/stars">stars</a>{{end}}<a href="/~{{$o.Name}}/followers"{{if eq .Data.Kind "followers"}} class="active"{{end}}>followers <span class="count">{{.Data.Followers}}</span></a>{{if not .Data.Org}}<a href="/~{{$o.Name}}/following"{{if eq .Data.Kind "following"}} class="active"{{end}}>following <span class="count">{{.Data.Following}}</span></a>{{end}}</nav>
5+<nav class="tabs small"><a href="/~{{$o.Name}}">about</a><a href="/~{{$o.Name}}/repos">repos</a>{{if not .Data.Org}}<a href="/~{{$o.Name}}/stars">stars</a><a href="/~{{$o.Name}}/gists">gists</a>{{end}}<a href="/~{{$o.Name}}/followers"{{if eq .Data.Kind "followers"}} class="active"{{end}}>followers <span class="count">{{.Data.Followers}}</span></a>{{if not .Data.Org}}<a href="/~{{$o.Name}}/following"{{if eq .Data.Kind "following"}} class="active"{{end}}>following <span class="count">{{.Data.Following}}</span></a>{{end}}</nav>
66 </div>
77 {{range .Data.People}}<div class="hit user"><a href="/~{{.Name}}" class="hitavatar"><img src="/~{{.Name}}/avatar?v={{.Profile.Avatar}}" alt=""></a><div><h4><a href="/~{{.Name}}">{{with .Profile.Display}}{{.}}{{else}}~{{.Name}}{{end}}</a> <span class="muted">~{{.Name}}</span></h4>{{with .Profile.Bio}}<p class="bio">{{.}}</p>{{end}}<p class="meta">{{with .Profile.Location}}<span>{{.}}</span>{{end}}<span>Joined {{.Created.Format "January 2006"}}</span></p></div></div>
88 {{else}}<p class="muted">{{if eq .Data.Kind "followers"}}Nobody follows ~{{$o.Name}} yet.{{else}}~{{$o.Name}} does not follow anyone yet.{{end}}</p>{{end}}

web/templates/gist.tmpl added

@@ -0,0 +1,38 @@
1+{{define "content"}}
2+{{$d := .Data}}
3+{{$g := $d.Gist}}
4+<div class="gisthead">
5+<div>
6+<h2><a href="/~{{$g.Owner.Name}}">~{{$g.Owner.Name}}</a> / <a href="/gist/{{$g.ID}}">{{$d.Title}}</a>{{if $g.Secret}} <span class="tag">secret</span>{{end}}</h2>
7+<p class="muted small">{{with $d.Last}}{{template "who" .}} · {{end}}created {{ago $g.Created}}{{if ne $g.Updated $g.Created}} · updated {{ago $g.Updated}}{{end}}{{with $d.Parent}} · forked from <a href="/gist/{{.ID}}">~{{.Owner.Name}}/{{.Title nil}}</a>{{end}}{{if .User}} · <a class="muted" href="/report?kind=gist&amp;target={{$g.ID}}">report</a>{{end}}</p>
8+{{if $g.Secret}}<p class="muted small">Secret gists are unlisted, anyone with this link can read them.</p>{{end}}
9+</div>
10+<div class="gistactions">
11+{{if .User}}<form method="post" action="/gist/{{$g.ID}}/star" class="inline"><button class="btn{{if $d.Starred}} starred{{end}}">{{if $d.Starred}}Unstar{{else}}Star{{end}} <span class="count">{{$g.Stars}}</span></button></form>
12+{{if not $d.Owner}}<form method="post" action="/gist/{{$g.ID}}/fork" class="inline"><button class="btn">Fork <span class="count">{{$g.Forks}}</span></button></form>{{end}}{{end}}
13+{{if $d.Owner}}<a class="btn" href="/gist/{{$g.ID}}/edit">Edit</a>{{end}}
14+{{if or $d.Owner $d.Admin}}<form method="post" action="/gist/{{$g.ID}}/delete" class="inline"><button class="btn danger">Delete</button></form>{{end}}
15+</div>
16+</div>
17+<nav class="tabs small gisttabs"><a class="active" href="/gist/{{$g.ID}}">code</a><a href="/gist/{{$g.ID}}/revisions">revisions</a></nav>
18+{{if $d.Old}}<p class="info">You are looking at an older revision, <a href="/gist/{{$g.ID}}">see the latest</a>.</p>{{end}}
19+<details class="clonebox"><summary>Clone, embed or share</summary>
20+<dl class="meta">
21+<dt>https</dt><dd><code>{{$d.Clone}}</code></dd>
22+<dt>ssh</dt><dd><code>{{$d.SSH}}</code></dd>
23+<dt>embed</dt><dd><code>{{$d.Embed}}</code></dd>
24+<dt>api</dt><dd><code>https://{{.Host}}/api/gists/{{$g.ID}}</code></dd>
25+</dl>
26+</details>
27+{{range $d.Files}}<section class="gistfile" id="file-{{.Name}}">
28+<div class="gistfilebar"><a href="#file-{{.Name}}">{{.Name}}</a><span class="muted small">{{size .Size}}</span><span class="spacer"></span><a href="{{.Raw}}">raw</a></div>
29+{{template "gistbody" .}}
30+</section>{{end}}
31+{{with $d.Forks}}<h4 class="rule">Forks</h4><ul class="gistforks">{{range .}}<li><a href="/gist/{{.ID}}">~{{.Owner.Name}}</a> <span class="muted small">{{ago .Created}}</span></li>{{end}}</ul>{{end}}
32+<h4 class="rule" id="comments">Comments{{with $d.Comments}} <span class="count">{{len .}}</span>{{end}}</h4>
33+{{range $d.Comments}}<div class="comment"><div class="who"><a href="/~{{.Comment.Author.Name}}">~{{.Comment.Author.Name}}</a> <span class="muted">{{ago .Comment.Created}}</span>{{if $.User}}{{if or (eq $.User.ID .Comment.Author.ID) $d.Owner $d.Admin}} <form method="post" action="/gist/{{$g.ID}}/comment/{{.Comment.ID}}/delete" class="inlineform"><button class="linkbutton small">delete</button></form>{{end}}{{end}}</div><div class="readme">{{.Body}}</div></div>{{end}}
34+{{if .User}}<form method="post" action="/gist/{{$g.ID}}/comment" class="stack wide">
35+<label>Leave a comment <textarea name="body" rows="4" required placeholder="Markdown supported" data-md></textarea></label>
36+<div class="actions"><button class="btn primary">Comment ▸</button></div>
37+</form>{{else}}<p class="muted"><a href="/login">Log in</a> to comment.</p>{{end}}
38+{{end}}

web/templates/gistform.tmpl added

@@ -0,0 +1,20 @@
1+{{define "content"}}
2+{{$d := .Data}}
3+<h3>{{if $d.Gist}}Edit gist{{else}}New gist{{end}}</h3>
4+<p class="muted">{{if $d.Gist}}Each save becomes a new revision. Clear a file's contents to remove it.{{else}}A gist is a small git repository of flat files. Empty files are dropped and a file without a name becomes <code>gistfile1.txt</code>.{{end}}</p>
5+<form method="post" action="{{$d.Action}}" class="stack wide gistform">
6+{{with $d.Head}}<input type="hidden" name="head" value="{{.}}">{{end}}
7+<label>Description <input name="description" maxlength="300" placeholder="What is this gist about" value="{{$d.Description}}"></label>
8+{{range $d.Files}}<div class="gistedit">
9+<input type="hidden" name="orig" value="{{.Orig}}">
10+<input name="name" maxlength="100" placeholder="Filename including extension" value="{{.Name}}" aria-label="Filename">
11+<textarea name="content" rows="14" spellcheck="false" aria-label="Contents">{{.Content}}</textarea>
12+</div>{{end}}
13+{{with $d.Keep}}<p class="muted small">Kept unchanged because they are binary or too large to edit here: {{range $i, $e := .}}{{if $i}}, {{end}}<code>{{$e.Name}}</code>{{end}}.</p>{{end}}
14+<div class="gistopts">
15+<label class="radio"><input type="radio" name="visibility" value="public"{{if not $d.Secret}} checked{{end}}> Public<small>Listed on your profile and in the gist index</small></label>
16+<label class="radio"><input type="radio" name="visibility" value="secret"{{if $d.Secret}} checked{{end}}> Secret<small>Unlisted, anyone with the link can read it</small></label>
17+</div>
18+<div class="actions"><button class="btn" name="action" value="add" formnovalidate>Add file</button><button class="btn primary" name="action" value="save">{{if $d.Gist}}Save revision{{else}}Create gist{{end}} ▸</button></div>
19+</form>
20+{{end}}

web/templates/gistlog.tmpl added

@@ -0,0 +1,16 @@
1+{{define "content"}}
2+{{$d := .Data}}
3+{{$g := $d.Gist}}
4+<div class="gisthead"><div><h2><a href="/~{{$g.Owner.Name}}">~{{$g.Owner.Name}}</a> / <a href="/gist/{{$g.ID}}">gist {{short $g.ID}}</a></h2></div></div>
5+<nav class="tabs small gisttabs"><a href="/gist/{{$g.ID}}">code</a><a class="active" href="/gist/{{$g.ID}}/revisions">revisions <span class="count">{{len $d.Revisions}}</span></a></nav>
6+{{range $d.Revisions}}{{$c := .Commit}}<div class="revision">
7+<p><a class="hash" href="/gist/{{$g.ID}}?rev={{$c.Hash}}">{{short $c.Hash}}</a> {{template "seal" $c}} {{template "who" $c}} <span class="muted">{{$c.Subject}} · {{ago $c.Time}}</span></p>
8+{{range .Files}}<section class="diff">
9+<h4>{{if eq .Status "renamed"}}{{.Old}} → {{end}}{{.New}} <span class="muted">{{.Status}}</span> <span class="add">+{{.Added}}</span> <span class="del">−{{.Removed}}</span></h4>
10+{{if .Binary}}<p class="muted">Binary file.</p>{{end}}
11+{{range .Hunks}}<table class="hunk"><tr class="head"><td colspan="3">{{.Header}}</td></tr>
12+{{range .Lines}}<tr class="{{.Kind}}"><td class="n">{{if .Old}}{{.Old}}{{end}}</td><td class="n">{{if .New}}{{.New}}{{end}}</td><td class="l">{{if eq .Kind "add"}}+{{else if eq .Kind "del"}}-{{else if eq .Kind "ctx"}} {{end}}{{.Text}}</td></tr>{{end}}
13+</table>{{end}}
14+</section>{{end}}
15+</div>{{end}}
16+{{end}}

web/templates/gists.tmpl added

@@ -0,0 +1,15 @@
1+{{define "content"}}
2+{{$d := .Data}}
3+{{with $d.Owner}}<div class="userhead">
4+<h2>~{{.Name}}</h2>
5+{{template "gisttabs" .}}
6+{{if $d.Own}}<a class="btn primary newrepo" href="/gists/new">New gist</a>{{end}}
7+</div>
8+<nav class="tabs small gistfilter"><a href="/~{{.Name}}/gists"{{if not $d.Starred}} class="active"{{end}}>all</a><a href="/~{{.Name}}/gists?tab=starred"{{if $d.Starred}} class="active"{{end}}>starred</a></nav>
9+{{else}}<div class="userhead"><h2>Gists</h2><p class="muted">Share snippets, notes and single files. Every gist is a git repository you can clone, fork and push to.</p>{{if $.User}}<a class="btn primary newrepo" href="/gists/new">New gist</a>{{end}}</div>{{end}}
10+{{if $d.Items}}<div class="events gists">{{range $d.Items}}{{$g := .Gist}}<div class="event gistitem">
11+<h4><a href="/~{{$g.Owner.Name}}">~{{$g.Owner.Name}}</a> / <a href="/gist/{{$g.ID}}">{{$g.Title .Files}}</a>{{if $g.Secret}} <span class="tag">secret</span>{{end}}</h4>
12+<p class="muted small">{{len .Files}} file{{if ne (len .Files) 1}}s{{end}} · updated {{ago $g.Updated}}{{if $g.Stars}} · {{$g.Stars}} star{{if ne $g.Stars 1}}s{{end}}{{end}}{{if $g.Forks}} · {{$g.Forks}} fork{{if ne $g.Forks 1}}s{{end}}{{end}}{{if $g.Comments}} · {{$g.Comments}} comment{{if ne $g.Comments 1}}s{{end}}{{end}}</p>
13+{{range .Preview}}<a class="gistpreview" href="/gist/{{$g.ID}}" aria-label="{{.Name}}">{{template "gistbody" .}}</a>{{end}}
14+</div>{{end}}</div>{{else}}<p class="info">{{if $d.Starred}}No starred gists yet.{{else}}No gists yet.{{end}}</p>{{end}}
15+{{end}}

web/templates/layout.tmpl modified

@@ -21,6 +21,7 @@
2121 <nav class="site">
2222 <a class="brand" href="/" aria-label="{{.Name}}">{{if .Logo}}<img src="/static/site/logo.png?v={{.Version}}" alt="{{.Name}}">{{else if eq .Name "gitpocket"}}<img class="solo" src="/static/logo.png?v={{.Version}}" alt="gitpocket" width="35" height="36">{{else}}<img class="mark" src="/static/logo.png?v={{$.Version}}" alt="" width="35" height="36">{{.Name}}{{end}}</a>
2323 <a href="/search">explore</a>
24+<a href="/gists">gists</a>
2425 {{if not (and .Data (index .Data "Facets"))}}<form class="navsearch" method="get" action="/search" role="search"><input name="q" placeholder="Search repositories, people, languages" aria-label="Search"></form>{{end}}
2526 <span class="spacer"></span>
2627 {{if .User}}<span class="me"><details class="bell{{if .Unread}} on{{end}}"><summary aria-label="Notifications{{if .Unread}}, {{.Unread}} unread{{end}}" title="Notifications"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 1.8a4 4 0 0 0-4 4v2.6L2.6 11h10.8L12 8.4V5.8a4 4 0 0 0-4-4zM6.4 13a1.6 1.6 0 0 0 3.2 0"/></svg>{{if .Unread}}<span class="badge">{{if gt .Unread 99}}99+{{else}}{{.Unread}}{{end}}</span>{{end}}</summary><div class="inboxpanel"><input type="radio" name="nview" id="nall" checked><input type="radio" name="nview" id="nunread"{{if .Unread}} checked{{end}}><input type="radio" name="nkind" id="kall" checked><input type="radio" name="nkind" id="kpull"><input type="radio" name="nkind" id="kissue"><div class="seg"><span><label for="nall">Inbox</label><label for="nunread">Unread</label></span><span><label for="kall">All</label><label for="kpull">Pulls</label><label for="kissue">Issues</label></span></div><ul>{{range .Inbox}}<li class="{{.Kind}}{{if .Unread}} unread{{end}}"><a href="/notifications/{{.ID}}"><svg class="kind" viewBox="0 0 16 16" aria-hidden="true">{{if eq .Kind "pull"}}<circle cx="4" cy="3.5" r="1.6"/><circle cx="4" cy="12.5" r="1.6"/><circle cx="12" cy="12.5" r="1.6"/><path d="M4 5.1v5.8M12 10.9V7a2.5 2.5 0 0 0-2.5-2.5H7m1.5-2L7 4.5l1.5 2"/>{{else}}<circle cx="8" cy="8" r="6"/><circle cx="8" cy="8" r="1.2"/>{{end}}</svg><span><span class="where">{{.Repo.Path}} #{{.Number}}</span><span class="title">{{.Title}}</span><span class="who">~{{.Actor}} {{.Verb}} this · {{ago .Created}}</span></span></a></li>{{end}}</ul><div class="empty"><svg class="tray" viewBox="0 0 24 24" aria-hidden="true"><path d="M3 13l2.5-7.5A2 2 0 0 1 7.4 4h9.2a2 2 0 0 1 1.9 1.5L21 13v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2zM3 13h5l1.5 2.5h5L16 13h5"/></svg><p>All caught up</p></div><a class="viewall" href="/notifications">View all ▸</a></div></details>{{if .Admin}}<a href="/admin">admin</a>{{end}}<span>Logged in as <a href="/~{{.User.Name}}">~{{.User.Name}}</a></span><form method="post" action="/logout"><button>Log out</button></form></span>{{else}}<span class="me auth"><a href="/login">Log in</a>{{if ne .Registration "closed"}}<a href="/register">Register</a>{{end}}</span>{{end}}

web/templates/meta.tmpl modified

@@ -14,3 +14,5 @@
1414 {{define "pushed"}}{{$p := .Page}}{{range .Pushed}}<div class="pushnote"><svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="4" cy="3" r="1.6"/><circle cx="4" cy="13" r="1.6"/><circle cx="12" cy="5" r="1.6"/><path d="M4 4.6v6.8M12 6.6c0 3-4 2.6-7.4 5.2"/></svg><span><strong>{{.Name}}</strong> had recent pushes {{ago .Time}}, {{.Ahead}} commit{{if ne .Ahead 1}}s{{end}} ahead of {{$p.Ref}}</span><a class="btn primary" href="/{{$p.Repo.Path}}/pulls/new?source={{$p.Repo.Path}}:{{.Name}}&amp;target={{$p.Ref}}">Open pull request ▸</a></div>{{end}}{{end}}
1515 {{define "lock"}}<svg class="lock" viewBox="0 0 16 16" aria-hidden="true"><path d="M4 7V5a4 4 0 0 1 8 0v2h.5A1.5 1.5 0 0 1 14 8.5v5a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 2 13.5v-5A1.5 1.5 0 0 1 3.5 7H4zm1.5 0h5V5a2.5 2.5 0 0 0-5 0v2z"/></svg>{{end}}
1616 {{define "seal"}}{{with signer .}}<span class="seal ok" title="Signed with a key registered by ~{{.}}">{{template "lock"}}<img src="/~{{.}}/avatar" alt="~{{.}}"></span>{{else}}{{if and .Signature (ne .Signature "N")}}<span class="seal" title="Signed with an unregistered key">{{template "lock"}}</span>{{end}}{{end}}{{end}}
17+{{define "gistbody"}}{{if .Image}}<div class="media"><img src="{{.Raw}}" alt="{{.Name}}"></div>{{else if .Binary}}<p class="muted gistnote">Binary file, <a href="{{.Raw}}">view raw</a>.</p>{{else if .Large}}<p class="muted gistnote">File too large to display, <a href="{{.Raw}}">view raw</a>.</p>{{else if .Markdown}}<article class="readme gistmd">{{.Markdown}}</article>{{else if .Highlighted}}<div class="code">{{.Highlighted}}</div>{{else}}<div class="code"><table class="blob">{{range $i, $l := lines .Content}}<tr><td class="n">{{inc $i}}</td><td class="l">{{$l}}</td></tr>{{end}}</table></div>{{end}}{{end}}
18+{{define "gisttabs"}}<nav class="tabs small"><a href="/~{{.Name}}">about</a><a href="/~{{.Name}}/repos">repos</a><a href="/~{{.Name}}/stars">stars</a><a class="active" href="/~{{.Name}}/gists">gists</a></nav>{{end}}

web/templates/repos.tmpl modified

@@ -2,7 +2,7 @@
22 {{$d := .Data}}
33 <div class="userhead">
44 <h2>~{{$d.Owner.Name}}</h2>
5-<nav class="tabs small"><a href="/~{{$d.Owner.Name}}">about</a><a class="active" href="/~{{$d.Owner.Name}}/repos">repos{{if $d.Repos}} <span class="count">{{len $d.Repos}}</span>{{end}}</a>{{if not $d.Org}}<a href="/~{{$d.Owner.Name}}/stars">stars{{if $d.Count}} <span class="count">{{$d.Count}}</span>{{end}}</a>{{end}}</nav>
5+<nav class="tabs small"><a href="/~{{$d.Owner.Name}}">about</a><a class="active" href="/~{{$d.Owner.Name}}/repos">repos{{if $d.Repos}} <span class="count">{{len $d.Repos}}</span>{{end}}</a>{{if not $d.Org}}<a href="/~{{$d.Owner.Name}}/stars">stars{{if $d.Count}} <span class="count">{{$d.Count}}</span>{{end}}</a><a href="/~{{$d.Owner.Name}}/gists">gists</a>{{end}}</nav>
66 {{if $d.Member}}<a class="btn primary newrepo" href="/create{{if $d.Org}}?owner={{$d.Owner.Name}}{{end}}">New repository</a>{{end}}
77 </div>
88 {{if $d.Repos}}<div class="events">{{range $d.Repos}}<div class="event repo"><h4><a href="/{{.Path}}">{{.Name}}</a>{{if .Private}} <span class="tag">private</span>{{else if .Unlisted}} <span class="tag">unlisted</span>{{end}}</h4>{{if .Description}}<p>{{.Description}}</p>{{end}}{{if .Tags}}<p class="tags">{{range .Tags}}<a href="/search?q=tag:{{.}}">#{{.}}</a> {{end}}</p>{{end}}{{template "repometa" .}}</div>{{end}}</div>{{else}}<p class="info">No repos yet.</p>{{end}}

web/templates/stars.tmpl modified

@@ -1,7 +1,7 @@
11 {{define "content"}}
22 <div class="userhead">
33 <h2>~{{.Data.Owner.Name}}</h2>
4-<nav class="tabs small"><a href="/~{{.Data.Owner.Name}}">about</a><a href="/~{{.Data.Owner.Name}}/repos">repos</a><a class="active" href="/~{{.Data.Owner.Name}}/stars">stars{{if .Data.Count}} <span class="count">{{.Data.Count}}</span>{{end}}</a></nav>
4+<nav class="tabs small"><a href="/~{{.Data.Owner.Name}}">about</a><a href="/~{{.Data.Owner.Name}}/repos">repos</a><a class="active" href="/~{{.Data.Owner.Name}}/stars">stars{{if .Data.Count}} <span class="count">{{.Data.Count}}</span>{{end}}</a><a href="/~{{.Data.Owner.Name}}/gists">gists</a></nav>
55 </div>
66 {{if .Data.Starred}}<div class="events">{{range .Data.Starred}}<div class="event repo"><h4><a href="/~{{.Owner.Name}}">~{{.Owner.Name}}</a>/<a href="/{{.Path}}">{{.Name}}</a></h4>{{if .Description}}<p>{{.Description}}</p>{{end}}{{if .Tags}}<p class="tags">{{range .Tags}}<a href="/search?q=tag:{{.}}">#{{.}}</a> {{end}}</p>{{end}}{{template "repometa" .}}</div>{{end}}</div>{{else}}<p class="info">~{{.Data.Owner.Name}} has not starred anything yet.</p>{{end}}
77 {{end}}

web/templates/user.tmpl modified

@@ -3,7 +3,7 @@
33 {{$own := and .User (eq .User.ID .Data.Owner.ID)}}
44 <div class="userhead">
55 <h2>~{{.Data.Owner.Name}}</h2>
6-<nav class="tabs small"><a class="active" href="/~{{.Data.Owner.Name}}">about</a><a href="/~{{.Data.Owner.Name}}/repos">repos{{if .Data.Total}} <span class="count">{{.Data.Total}}</span>{{end}}</a><a href="/~{{.Data.Owner.Name}}/stars">stars{{if .Data.Count}} <span class="count">{{.Data.Count}}</span>{{end}}</a></nav>
6+<nav class="tabs small"><a class="active" href="/~{{.Data.Owner.Name}}">about</a><a href="/~{{.Data.Owner.Name}}/repos">repos{{if .Data.Total}} <span class="count">{{.Data.Total}}</span>{{end}}</a><a href="/~{{.Data.Owner.Name}}/stars">stars{{if .Data.Count}} <span class="count">{{.Data.Count}}</span>{{end}}</a><a href="/~{{.Data.Owner.Name}}/gists">gists</a></nav>
77 </div>
88 <div class="columns user">
99 <aside class="profile">