feat(index): link the importer from the dashboard
7 files changed, +94 −12
- forge/flow_test.go modified +5 −2
- forge/import.go modified +25 −0
- forge/unit_test.go modified +27 −0
- forge/web.go modified +27 −8
- web/static/style.css modified +7 −0
- web/templates/blob.tmpl modified +2 −1
- web/templates/index.tmpl modified +1 −1
forge/flow_test.go modified
| @@ -14,6 +14,7 @@ import ( | ||
| 14 | 14 | "path/filepath" |
| 15 | 15 | "strings" |
| 16 | 16 | "testing" |
| 17 | + "time" | |
| 17 | 18 | ) |
| 18 | 19 | |
| 19 | 20 | func testServer(t *testing.T) (*Server, http.Handler) { |
| @@ -289,9 +290,11 @@ func TestPins(t *testing.T) { | ||
| 289 | 290 | } |
| 290 | 291 | pin(ids[2], own) |
| 291 | 292 | pin(ids[0], own) |
| 293 | + past := time.Now().Add(-time.Hour) | |
| 294 | + os.Chtimes(filepath.Join(s.root, "repos", "alice", "c.git"), past, past) | |
| 292 | 295 | body := get(h, "/~alice", "").Body.String() |
| 293 | - if !strings.Contains(body, `href="/~alice/c"`) || strings.Index(body, `href="/~alice/c"`) > strings.Index(body, `href="/~alice/a"`) || strings.Contains(body, `href="/~alice/b"`) { | |
| 294 | - t.Error("profile does not show exactly the pinned repos in pin order") | |
| 296 | + if !strings.Contains(body, `href="/~alice/c"`) || strings.Index(body, `href="/~alice/a"`) > strings.Index(body, `href="/~alice/c"`) || strings.Contains(body, `href="/~alice/b"`) { | |
| 297 | + t.Error("profile does not show exactly the pinned repos, most recently updated first") | |
| 295 | 298 | } |
| 296 | 299 | for _, id := range ids[1:] { |
| 297 | 300 | pin(id, own) |
forge/import.go modified
| @@ -9,6 +9,7 @@ import ( | ||
| 9 | 9 | "fmt" |
| 10 | 10 | "html/template" |
| 11 | 11 | "io" |
| 12 | + "io/fs" | |
| 12 | 13 | "log" |
| 13 | 14 | "net" |
| 14 | 15 | "net/http" |
| @@ -16,6 +17,7 @@ import ( | ||
| 16 | 17 | "os" |
| 17 | 18 | "os/exec" |
| 18 | 19 | "path" |
| 20 | + "path/filepath" | |
| 19 | 21 | "slices" |
| 20 | 22 | "strconv" |
| 21 | 23 | "strings" |
| @@ -225,6 +227,7 @@ func (s *Server) runImport(repo Repo, src, pin, token string) { | ||
| 225 | 227 | if over, used, quota := overQuota(s.root, repo.Owner.Name); over { |
| 226 | 228 | return errors.New(quotaMessage(used, quota)) |
| 227 | 229 | } |
| 230 | + settleRefs(dir) | |
| 228 | 231 | return nil |
| 229 | 232 | }() |
| 230 | 233 | if err != nil { |
| @@ -386,6 +389,7 @@ func (s *Server) claim(w http.ResponseWriter, r *http.Request, repo Repo) { | ||
| 386 | 389 | http.Redirect(w, r, "/"+repo.Path()+"/claim?error="+template.URLQueryEscaper("Could not rewrite the history"), http.StatusSeeOther) |
| 387 | 390 | return |
| 388 | 391 | } |
| 392 | + settleRefs(s.repoDir(repo)) | |
| 389 | 393 | } |
| 390 | 394 | http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther) |
| 391 | 395 | return |
| @@ -394,6 +398,27 @@ func (s *Server) claim(w http.ResponseWriter, r *http.Request, repo Repo) { | ||
| 394 | 398 | s.render(w, r, "claim", page{Title: "Claim commits", Error: r.URL.Query().Get("error"), Data: map[string]any{"Page": rp, "Identities": ids, "Name": name, "Email": email}}) |
| 395 | 399 | } |
| 396 | 400 | |
| 401 | +func settleRefs(dir string) { | |
| 402 | + out, err := Git{Dir: dir}.run(nil, "for-each-ref", "--sort=-creatordate", "--count=1", "--format=%(creatordate:unix)", "refs/heads", "refs/tags") | |
| 403 | + if err != nil { | |
| 404 | + return | |
| 405 | + } | |
| 406 | + unix, err := strconv.ParseInt(strings.TrimSpace(string(out)), 10, 64) | |
| 407 | + if err != nil { | |
| 408 | + return | |
| 409 | + } | |
| 410 | + stamp := time.Unix(unix, 0) | |
| 411 | + os.Chtimes(filepath.Join(dir, "packed-refs"), stamp, stamp) | |
| 412 | + for _, sub := range []string{"heads", "tags"} { | |
| 413 | + filepath.WalkDir(filepath.Join(dir, "refs", sub), func(p string, d fs.DirEntry, err error) error { | |
| 414 | + if err == nil && !d.IsDir() { | |
| 415 | + os.Chtimes(p, stamp, stamp) | |
| 416 | + } | |
| 417 | + return nil | |
| 418 | + }) | |
| 419 | + } | |
| 420 | +} | |
| 421 | + | |
| 397 | 422 | var signedCommitsFlag = sync.OnceValue(func() bool { |
| 398 | 423 | out, _ := exec.Command("git", "fast-export", "-h").CombinedOutput() |
| 399 | 424 | return bytes.Contains(out, []byte("signed-commits")) |
forge/unit_test.go modified
| @@ -9,6 +9,7 @@ import ( | ||
| 9 | 9 | "strconv" |
| 10 | 10 | "strings" |
| 11 | 11 | "testing" |
| 12 | + "time" | |
| 12 | 13 | ) |
| 13 | 14 | |
| 14 | 15 | func TestValidBranch(t *testing.T) { |
| @@ -192,3 +193,29 @@ func TestRewriteAuthors(t *testing.T) { | ||
| 192 | 193 | t.Error("tag not moved to the rewritten commit") |
| 193 | 194 | } |
| 194 | 195 | } |
| 196 | + | |
| 197 | +func TestSettleRefs(t *testing.T) { | |
| 198 | + dir := t.TempDir() | |
| 199 | + when := "2020-03-04T05:06:07Z" | |
| 200 | + for _, args := range [][]string{ | |
| 201 | + {"init", "-q", "-b", "main"}, | |
| 202 | + {"-c", "user.name=a", "-c", "user.email=a@b", "commit", "-q", "--allow-empty", "-m", "old"}, | |
| 203 | + {"tag", "v1"}, | |
| 204 | + {"pack-refs", "--all"}, | |
| 205 | + {"branch", "feat/x"}, | |
| 206 | + } { | |
| 207 | + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) | |
| 208 | + cmd.Env = append(os.Environ(), "GIT_AUTHOR_DATE="+when, "GIT_COMMITTER_DATE="+when) | |
| 209 | + if out, err := cmd.CombinedOutput(); err != nil { | |
| 210 | + t.Fatalf("%v: %s", err, out) | |
| 211 | + } | |
| 212 | + } | |
| 213 | + gitDir := filepath.Join(dir, ".git") | |
| 214 | + if repoUpdated(gitDir).Before(time.Now().Add(-time.Hour)) { | |
| 215 | + t.Fatal("fresh refs already look old") | |
| 216 | + } | |
| 217 | + settleRefs(gitDir) | |
| 218 | + if got := repoUpdated(gitDir).UTC().Format(time.RFC3339); got != when { | |
| 219 | + t.Errorf("updated %s, want %s", got, when) | |
| 220 | + } | |
| 221 | +} | |
forge/web.go modified
| @@ -147,6 +147,7 @@ func newServer(store *Store, root, host, name string, sshUser string) *Server { | ||
| 147 | 147 | s.policy.AllowElements("video", "source") |
| 148 | 148 | s.policy.AllowAttrs("src", "type").OnElements("video", "source") |
| 149 | 149 | s.policy.AllowAttrs("controls", "muted", "loop", "playsinline", "poster", "width", "height").OnElements("video") |
| 150 | + s.policy.AllowAttrs("align").Matching(regexp.MustCompile(`^(center|left|right)$`)).OnElements("p", "div", "h1", "h2", "h3", "h4") | |
| 150 | 151 | s.writeSigners() |
| 151 | 152 | s.writeKeyring() |
| 152 | 153 | s.installHooks() |
| @@ -588,10 +589,12 @@ func repoUpdated(dir string) time.Time { | ||
| 588 | 589 | } |
| 589 | 590 | consider(filepath.Join(dir, "packed-refs")) |
| 590 | 591 | for _, sub := range []string{"heads", "tags"} { |
| 591 | - entries, _ := os.ReadDir(filepath.Join(dir, "refs", sub)) | |
| 592 | - for _, e := range entries { | |
| 593 | - consider(filepath.Join(dir, "refs", sub, e.Name())) | |
| 594 | - } | |
| 592 | + filepath.WalkDir(filepath.Join(dir, "refs", sub), func(p string, d fs.DirEntry, err error) error { | |
| 593 | + if err == nil && !d.IsDir() { | |
| 594 | + consider(p) | |
| 595 | + } | |
| 596 | + return nil | |
| 597 | + }) | |
| 595 | 598 | } |
| 596 | 599 | if latest.IsZero() { |
| 597 | 600 | consider(dir) |
| @@ -1188,7 +1191,18 @@ func (s *Server) renderMarkdown(src []byte) template.HTML { | ||
| 1188 | 1191 | return s.renderMarkdownAt(src, "", "") |
| 1189 | 1192 | } |
| 1190 | 1193 | |
| 1191 | -var relLink = regexp.MustCompile(`(src|href)="([^"]+)"`) | |
| 1194 | +var ( | |
| 1195 | + relLink = regexp.MustCompile(`(src|href|poster)="([^"]+)"`) | |
| 1196 | + posterLink = regexp.MustCompile(`<a href="([^"]+\.(?i:mp4|webm|ogv|mov))"[^>]*>\s*<img src="([^"]+)"[^>]*>\s*</a>`) | |
| 1197 | + videoImg = regexp.MustCompile(`<img src="([^"]+\.(?i:mp4|webm|ogv|mov))"[^>]*>`) | |
| 1198 | + videoLine = regexp.MustCompile(`<p>\s*<a href="([^"]+\.(?i:mp4|webm|ogv|mov))"[^>]*>[^<]*</a>\s*</p>`) | |
| 1199 | +) | |
| 1200 | + | |
| 1201 | +func embedVideos(out []byte) []byte { | |
| 1202 | + out = posterLink.ReplaceAll(out, []byte(`<video controls playsinline preload="metadata" src="$1" poster="$2"></video>`)) | |
| 1203 | + out = videoImg.ReplaceAll(out, []byte(`<video controls playsinline preload="metadata" src="$1"></video>`)) | |
| 1204 | + return videoLine.ReplaceAll(out, []byte(`<p><video controls playsinline preload="metadata" src="$1"></video></p>`)) | |
| 1205 | +} | |
| 1192 | 1206 | |
| 1193 | 1207 | func (s *Server) renderMarkdownAt(src []byte, rawBase, treeBase string) template.HTML { |
| 1194 | 1208 | src = displayMath.ReplaceAll(src, []byte("$1$$$$\n$2\n$$$$")) |
| @@ -1196,7 +1210,7 @@ func (s *Server) renderMarkdownAt(src []byte, rawBase, treeBase string) template | ||
| 1196 | 1210 | if err := s.markdown.Convert(src, &buf); err != nil { |
| 1197 | 1211 | return template.HTML("<pre>" + template.HTMLEscapeString(string(src)) + "</pre>") |
| 1198 | 1212 | } |
| 1199 | - out := s.policy.SanitizeBytes(buf.Bytes()) | |
| 1213 | + out := embedVideos(s.policy.SanitizeBytes(buf.Bytes())) | |
| 1200 | 1214 | if rawBase != "" { |
| 1201 | 1215 | out = relLink.ReplaceAllFunc(out, func(m []byte) []byte { |
| 1202 | 1216 | parts := relLink.FindSubmatch(m) |
| @@ -1206,7 +1220,7 @@ func (s *Server) renderMarkdownAt(src []byte, rawBase, treeBase string) template | ||
| 1206 | 1220 | } |
| 1207 | 1221 | target = strings.TrimPrefix(target, "./") |
| 1208 | 1222 | base := treeBase |
| 1209 | - if attr == "src" { | |
| 1223 | + if attr != "href" { | |
| 1210 | 1224 | base = rawBase |
| 1211 | 1225 | } |
| 1212 | 1226 | return []byte(attr + `="` + html.EscapeString(escapePath(base)) + target + `"`) |
| @@ -1286,6 +1300,12 @@ func (s *Server) tree(w http.ResponseWriter, r *http.Request, repo Repo, spec st | ||
| 1286 | 1300 | case "blob": |
| 1287 | 1301 | size := g.Size(rp.Commit, filePath) |
| 1288 | 1302 | data["Size"] = size |
| 1303 | + if kind, ok := mediaTypes[strings.ToLower(filepath.Ext(filePath))]; ok && size <= maxMedia { | |
| 1304 | + data["File"] = true | |
| 1305 | + data["Media"], _, _ = strings.Cut(kind, "/") | |
| 1306 | + s.render(w, r, "blob", page{Title: repo.Path() + "/" + filePath, Data: data}) | |
| 1307 | + return | |
| 1308 | + } | |
| 1289 | 1309 | var content []byte |
| 1290 | 1310 | if size <= maxRender { |
| 1291 | 1311 | if content, err = g.Blob(rp.Commit, filePath); err != nil { |
| @@ -1720,7 +1740,6 @@ func pinnedRepos(repos []Repo, pins map[int64]int) []Repo { | ||
| 1720 | 1740 | out = append(out, r) |
| 1721 | 1741 | } |
| 1722 | 1742 | } |
| 1723 | - slices.SortFunc(out, func(a, b Repo) int { return pins[a.ID] - pins[b.ID] }) | |
| 1724 | 1743 | return out |
| 1725 | 1744 | } |
| 1726 | 1745 | |
web/static/style.css modified
| @@ -333,6 +333,13 @@ h3.featured{font-size:1.3rem;font-weight:400;margin:2rem 0 .5rem} | ||
| 333 | 333 | .landing .more .btn{min-width:20rem;text-align:center} |
| 334 | 334 | @media (max-width:700px){.featuredgrid{grid-template-columns:1fr}.landing .more .btn{min-width:0;width:100%}} |
| 335 | 335 | .readme video,.readme img{max-width:100%;height:auto} |
| 336 | +.readme video{display:block;margin:.6rem auto;background:#000} | |
| 337 | +.readme [align=center]{text-align:center} | |
| 338 | +.readme [align=right]{text-align:right} | |
| 339 | +.media{display:flex;justify-content:center;padding:1rem 0} | |
| 340 | +.media video,.media img{max-width:100%;max-height:80vh;height:auto} | |
| 341 | +.media video{background:#000} | |
| 342 | +.media audio{width:min(100%,40rem)} | |
| 336 | 343 | .auth h3{white-space:nowrap} |
| 337 | 344 | .pickaxe{display:flex;gap:.5rem;margin:0 0 1rem;flex-wrap:wrap}.pickaxe input{flex:1;min-width:14rem} |
| 338 | 345 | .pickaxe select{font:inherit;color:var(--fg);background:var(--bg);border:1px solid var(--edge);padding:.3rem .4rem} |
web/templates/blob.tmpl modified
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | {{define "content"}} |
| 2 | 2 | {{$p := .Data.Page}} |
| 3 | 3 | {{template "filebar" .Data}} |
| 4 | -{{if .Data.Binary}}<p class="muted">Binary file, <a href="/{{$p.Repo.Path}}/raw/{{$p.Ref}}/{{.Data.Path}}">view raw</a>.</p> | |
| 4 | +{{if .Data.Media}}{{$src := printf "/%s/raw/%s/%s" $p.Repo.Path $p.Ref .Data.Path}}<div class="media">{{if eq .Data.Media "video"}}<video controls playsinline preload="metadata" src="{{$src}}"></video>{{else if eq .Data.Media "audio"}}<audio controls preload="metadata" src="{{$src}}"></audio>{{else}}<img src="{{$src}}" alt="{{.Data.Path}}">{{end}}</div> | |
| 5 | +{{else if .Data.Binary}}<p class="muted">Binary file, <a href="/{{$p.Repo.Path}}/raw/{{$p.Ref}}/{{.Data.Path}}">view raw</a>.</p> | |
| 5 | 6 | {{else if .Data.Large}}<p class="muted">File too large to display, <a href="/{{$p.Repo.Path}}/raw/{{$p.Ref}}/{{.Data.Path}}">view raw</a>.</p> |
| 6 | 7 | {{else if .Data.Markdown}}<p class="muted small"><a href="?source=1">View source</a></p><article class="readme">{{.Data.Markdown}}</article> |
| 7 | 8 | {{else if .Data.Highlighted}}<div class="code">{{.Data.Highlighted}}</div> |
web/templates/index.tmpl modified
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | {{define "content"}} |
| 2 | 2 | {{if .User}} |
| 3 | 3 | <div class="dash"> |
| 4 | -<section><div class="dashhead"><h3>Your repositories</h3><span><a class="btn" href="/orgs/new">New organization</a> <a class="btn primary" href="/create">New repository</a></span></div> | |
| 4 | +<section><div class="dashhead"><h3>Your repositories</h3><span><a class="btn" href="/orgs/new">New organization</a> <a class="btn" href="/import">Import repository</a> <a class="btn primary" href="/create">New repository</a></span></div> | |
| 5 | 5 | {{if .Data.Repos}}{{template "repolist" .Data.Repos}}{{else}}<p class="muted">You have no repositories yet.</p>{{end}} |
| 6 | 6 | {{if .Data.Shared}}<h3>Shared with you</h3>{{template "repolist" .Data.Shared}}{{end}}</section> |
| 7 | 7 | {{else}} |