feat(gists): host multi-file gists as git repositories with a cookie-less raw origin
20 files changed, +1585 −12
- forge/agit.go modified +7 −4
- forge/backup.go modified +4 −0
- forge/gist.go added +578 −0
- forge/gist_test.go added +187 −0
- forge/gistweb.go added +615 −0
- forge/moderation.go modified +7 −1
- forge/shell.go modified +49 −0
- forge/store.go modified +3 −3
- forge/web.go modified +14 −0
- web/static/style.css modified +25 −0
- web/templates/follows.tmpl modified +1 −1
- web/templates/gist.tmpl added +38 −0
- web/templates/gistform.tmpl added +20 −0
- web/templates/gistlog.tmpl added +16 −0
- web/templates/gists.tmpl added +15 −0
- web/templates/layout.tmpl modified +1 −0
- web/templates/meta.tmpl modified +2 −0
- web/templates/repos.tmpl modified +1 −1
- web/templates/stars.tmpl modified +1 −1
- web/templates/user.tmpl modified +1 −1
forge/agit.go modified
| @@ -122,15 +122,18 @@ func preReceive(root string, in io.Reader, errw io.Writer) int { | ||
| 122 | 122 | fmt.Fprintf(errw, "gitpocket: "+format+"\n", args...) |
| 123 | 123 | return 1 |
| 124 | 124 | } |
| 125 | - hc, err := loadHook(root, true) | |
| 126 | - if err != nil { | |
| 127 | - return refuse("%v, push refused", err) | |
| 128 | - } | |
| 129 | 125 | for _, kv := range os.Environ() { |
| 130 | 126 | if strings.HasPrefix(kv, "GIT_OBJECT_DIRECTORY=") || strings.HasPrefix(kv, "GIT_ALTERNATE_OBJECT_DIRECTORIES=") || strings.HasPrefix(kv, "GIT_QUARANTINE_PATH=") { |
| 131 | 127 | passEnv = append(passEnv, kv) |
| 132 | 128 | } |
| 133 | 129 | } |
| 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 | + } | |
| 134 | 137 | mirror := hc.store.IsPullMirror(hc.repo.ID) |
| 135 | 138 | if !hc.writer && (!hc.store.GuestPushBudget(hc.pusher.ID) || hc.store.Blocked(hc.repo.Owner.ID, hc.pusher.ID) || hc.repo.Archived || mirror) { |
| 136 | 139 | 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 { | ||
| 28 | 28 | } |
| 29 | 29 | var failed []string |
| 30 | 30 | repos, _ := filepath.Glob(filepath.Join(root, "repos", "*", "*.git")) |
| 31 | + gists, _ := filepath.Glob(filepath.Join(root, "repos", "*", ".gists", "*.git")) | |
| 32 | + repos = append(repos, gists...) | |
| 31 | 33 | live := map[string]bool{} |
| 32 | 34 | for _, repo := range repos { |
| 33 | 35 | rel, _ := filepath.Rel(filepath.Join(root, "repos"), repo) |
| @@ -41,6 +43,8 @@ func backup(root, dir string, keep int) error { | ||
| 41 | 43 | } |
| 42 | 44 | } |
| 43 | 45 | stale, _ := filepath.Glob(filepath.Join(dir, "repos", "*", "*.bundle")) |
| 46 | + staleGists, _ := filepath.Glob(filepath.Join(dir, "repos", "*", ".gists", "*.bundle")) | |
| 47 | + stale = append(stale, staleGists...) | |
| 44 | 48 | for _, b := range stale { |
| 45 | 49 | if !live[b] { |
| 46 | 50 | 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 ( | ||
| 43 | 43 | maxReason = 2000 |
| 44 | 44 | ) |
| 45 | 45 | |
| 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"} | |
| 47 | 47 | |
| 48 | 48 | type Report struct { |
| 49 | 49 | ID int64 |
| @@ -61,6 +61,9 @@ func (r Report) Link() string { | ||
| 61 | 61 | return "/~" + r.Target |
| 62 | 62 | case "repo": |
| 63 | 63 | return "/~" + r.Target |
| 64 | + case "gist": | |
| 65 | + _, id, _ := strings.Cut(r.Target, "/") | |
| 66 | + return "/gist/" + id | |
| 64 | 67 | case "issue", "pull": |
| 65 | 68 | repo, num, _ := strings.Cut(r.Target, "#") |
| 66 | 69 | 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) | ||
| 232 | 235 | case "user": |
| 233 | 236 | u, err := s.store.UserByName(target) |
| 234 | 237 | return u.Name, err == nil |
| 238 | + case "gist": | |
| 239 | + g, err := s.store.Gist(target) | |
| 240 | + return g.Owner.Name + "/" + g.ID, err == nil | |
| 235 | 241 | case "repo", "issue", "pull": |
| 236 | 242 | path, num, hasNum := strings.Cut(target, "#") |
| 237 | 243 | owner, name, ok := strings.Cut(path, "/") |
forge/shell.go modified
| @@ -46,6 +46,9 @@ func shell(root, name string) int { | ||
| 46 | 46 | arg = strings.Trim(strings.TrimSpace(arg), "'\"") |
| 47 | 47 | arg = strings.TrimPrefix(strings.TrimPrefix(arg, "/"), "~") |
| 48 | 48 | arg = strings.TrimSuffix(arg, ".git") |
| 49 | + if id, ok := strings.CutPrefix(arg, "gist/"); ok { | |
| 50 | + return gistShell(root, name, verb, id) | |
| 51 | + } | |
| 49 | 52 | owner, repoName, ok := strings.Cut(arg, "/") |
| 50 | 53 | if !ok || !validName(owner) || !validName(repoName) { |
| 51 | 54 | return fail("repository path must be ~owner/repo") |
| @@ -98,3 +101,49 @@ func shell(root, name string) int { | ||
| 98 | 101 | err = syscall.Exec(git, append(argv, strings.TrimPrefix(verb, "git-"), dir), env) |
| 99 | 102 | return fail(err.Error()) |
| 100 | 103 | } |
| 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) { | ||
| 279 | 279 | if _, err := db.Exec(schema); err != nil { |
| 280 | 280 | return nil, err |
| 281 | 281 | } |
| 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} { | |
| 283 | 283 | if _, err := db.Exec(ddl); err != nil { |
| 284 | 284 | return nil, err |
| 285 | 285 | } |
| @@ -308,7 +308,7 @@ func validName(name string) bool { | ||
| 308 | 308 | return nameRe.MatchString(name) && !strings.HasSuffix(name, ".git") |
| 309 | 309 | } |
| 310 | 310 | |
| 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} | |
| 312 | 312 | |
| 313 | 313 | func checkName(name string) error { |
| 314 | 314 | if !validName(name) { |
| @@ -1137,7 +1137,7 @@ func validEmail(email string) bool { | ||
| 1137 | 1137 | const maxPins = 6 |
| 1138 | 1138 | |
| 1139 | 1139 | 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" | |
| 1141 | 1141 | } |
| 1142 | 1142 | |
| 1143 | 1143 | func (s *Store) Pins(owner int64) map[int64]int { |
forge/web.go modified
| @@ -303,10 +303,20 @@ func (s *Server) handler() http.Handler { | ||
| 303 | 303 | mux.HandleFunc("POST /report", s.report) |
| 304 | 304 | mux.HandleFunc("POST /block", s.block) |
| 305 | 305 | 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) | |
| 306 | 312 | mux.HandleFunc("/", s.route) |
| 307 | 313 | cop := http.NewCrossOriginProtection() |
| 308 | 314 | site := secure(s.throttle(cop.Handler(mux))) |
| 309 | 315 | 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 | + } | |
| 310 | 320 | if host := bareHost(r.Host); host != s.host && !strings.HasSuffix(host, "."+s.host) { |
| 311 | 321 | if owner, ok := s.store.DomainOwner(host); ok { |
| 312 | 322 | s.servePages(w, r, owner.Name) |
| @@ -1020,6 +1030,10 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) { | ||
| 1020 | 1030 | s.repos(w, r, owner, user) |
| 1021 | 1031 | return |
| 1022 | 1032 | } |
| 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 | + } | |
| 1023 | 1037 | if name == "stars" && len(parts) == 2 && r.Method == "GET" { |
| 1024 | 1038 | starred, _ := s.store.StarredRepos(owner.ID) |
| 1025 | 1039 | 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} | ||
| 559 | 559 | .modlinks{margin:.6em 0;text-align:center} |
| 560 | 560 | button.link{background:none;border:0;padding:0;color:inherit;font:inherit;cursor:pointer;text-decoration:underline} |
| 561 | 561 | 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 @@ | ||
| 2 | 2 | {{$o := .Data.Owner}} |
| 3 | 3 | <div class="userhead"> |
| 4 | 4 | <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> | |
| 6 | 6 | </div> |
| 7 | 7 | {{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> |
| 8 | 8 | {{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&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 @@ | ||
| 21 | 21 | <nav class="site"> |
| 22 | 22 | <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> |
| 23 | 23 | <a href="/search">explore</a> |
| 24 | +<a href="/gists">gists</a> | |
| 24 | 25 | {{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}} |
| 25 | 26 | <span class="spacer"></span> |
| 26 | 27 | {{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 @@ | ||
| 14 | 14 | {{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}}&target={{$p.Ref}}">Open pull request ▸</a></div>{{end}}{{end}} |
| 15 | 15 | {{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}} |
| 16 | 16 | {{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 @@ | ||
| 2 | 2 | {{$d := .Data}} |
| 3 | 3 | <div class="userhead"> |
| 4 | 4 | <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> | |
| 6 | 6 | {{if $d.Member}}<a class="btn primary newrepo" href="/create{{if $d.Org}}?owner={{$d.Owner.Name}}{{end}}">New repository</a>{{end}} |
| 7 | 7 | </div> |
| 8 | 8 | {{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 @@ | ||
| 1 | 1 | {{define "content"}} |
| 2 | 2 | <div class="userhead"> |
| 3 | 3 | <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> | |
| 5 | 5 | </div> |
| 6 | 6 | {{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}} |
| 7 | 7 | {{end}} |
web/templates/user.tmpl modified
| @@ -3,7 +3,7 @@ | ||
| 3 | 3 | {{$own := and .User (eq .User.ID .Data.Owner.ID)}} |
| 4 | 4 | <div class="userhead"> |
| 5 | 5 | <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> | |
| 7 | 7 | </div> |
| 8 | 8 | <div class="columns user"> |
| 9 | 9 | <aside class="profile"> |