~gitpocket/gitpocket

8

feat(mirror): add pull and push mirrors with sealed credentials

commit
9964e9d0dda454f20256e9df8cb7ff2f59cb8d25 browse tree
parent
f09bf278b027a2c3136d5ebdfeb8149937ca7790
author
~james <354576047+[email protected]>
signature
Signed by: ~james SHA256:KlzJ0P0gvJcnOYpaxD5yCUw52ImHttTKRjVAEnnYnKg
date
2026-11-14 04:06 UTC

14 files changed, +603 −11

README.md modified

@@ -22,6 +22,7 @@ The web interface is then on `127.0.0.1:3200` and wants a reverse proxy with TLS
2222 - Organisations, collaborators, issues, labels, stars, forks, branch protection and per owner disk quotas enforced during the push
2323 - Profiles with a pinned repository list, an activity graph, followers and a README from your `~you/you` repository
2424 - Imports from GitHub, GitLab, Gitea or Forgejo over https, with the option to credit commits made under your old identity to your account
25+- Pull mirrors that fetch a source on a schedule and stay read only and push mirrors that copy every branch and tag to another host after each change
2526 - Static sites served from `<user>.<host>`, built from a `pages` repository or the `pages` branch of any public repository
2627 - Reports to admins and per account blocking, with hourly limits on new issues, pull requests and repositories
2728 - SSH and PGP commit verification, TOTP, access tokens and Atom feeds for commits, issues and pull requests
@@ -53,6 +54,10 @@ A public repository named `pages` is served at `https://you.git.example.org/` fr
5354
5455 The pages origin never sets or reads cookies and the main site only uses `__Host-` cookies, so a script running on a user site can neither read a session from the main host nor plant a cookie that the main host would later accept.
5556
57+### Mirrors
58+
59+Ticking *keep it as a read only mirror* on the import page turns the import into a pull mirror that fetches the source again every 1, 4, 8 or 24 hours and refuses pushes, web edits and merges in the meantime. Push mirrors are added under the repository settings with a URL, a username and a token, and they push with `--force --prune` whenever the refs change before retrying after ten minutes if a push fails. Every fetch and push checks the URL again and resolves it once to a public address that stays pinned for the whole transfer, with redirects refused and https as the only protocol allowed. Tokens are sealed with AES-GCM under `mirror.key` in the data directory so that a copy of the database on its own does not reveal any of them.
60+
5661 ## Development
5762
5863 ```

forge/cli.go modified

@@ -234,9 +234,11 @@ func serve(root string, args []string) {
234234 trustedProxies = append(trustedProxies, n)
235235 }
236236 store := mustStore(root)
237+ site := newServer(store, root, *host, *name, *sshUser)
238+ go site.mirrorLoop()
237239 srv := &http.Server{
238240 Addr: *listen,
239- Handler: newServer(store, root, *host, *name, *sshUser).handler(),
241+ Handler: site.handler(),
240242 ReadHeaderTimeout: 10 * time.Second,
241243 IdleTimeout: 2 * time.Minute,
242244 MaxHeaderBytes: 64 << 10,

forge/edit.go modified

@@ -150,6 +150,10 @@ func (s *Server) editAllowed(w http.ResponseWriter, r *http.Request, repo Repo,
150150 s.fail(w, r, 403, "This repository is archived")
151151 return rp, g, false
152152 }
153+ if rp.Mirror != "" {
154+ s.fail(w, r, 403, "This repository is a mirror and read only")
155+ return rp, g, false
156+ }
153157 if _, err := g.Resolve("refs/heads/" + branch); err != nil || !validBranch(branch) {
154158 s.fail(w, r, 404, "Edits need a branch, not a tag or commit")
155159 return rp, g, false

forge/feature_test.go modified

@@ -1,9 +1,11 @@
11 package forge
22
33 import (
4+ "bytes"
45 "net/http"
56 "net/http/httptest"
67 "net/url"
8+ "os"
79 "os/exec"
810 "path/filepath"
911 "strconv"
@@ -177,6 +179,66 @@ func TestReservedNames(t *testing.T) {
177179 }
178180 }
179181
182+func TestSeal(t *testing.T) {
183+ root := t.TempDir()
184+ box, err := seal(root, "hunter2")
185+ if err != nil || bytes.Contains(box, []byte("hunter2")) {
186+ t.Fatalf("seal: %v", err)
187+ }
188+ if got, err := unseal(root, box); err != nil || got != "hunter2" {
189+ t.Errorf("unseal: %q %v", got, err)
190+ }
191+ box[len(box)-1] ^= 1
192+ if _, err := unseal(root, box); err == nil {
193+ t.Error("tampered credential accepted")
194+ }
195+ if fi, _ := os.Stat(filepath.Join(root, "mirror.key")); fi.Mode().Perm() != 0o600 {
196+ t.Errorf("key mode %v", fi.Mode().Perm())
197+ }
198+}
199+
200+func TestMirrorSettings(t *testing.T) {
201+ s, h := testServer(t)
202+ alice, _ := s.store.CreateUser("alice", "correct horse")
203+ bob, _ := s.store.CreateUser("bob", "correct horse")
204+ repo, g := newRepo(t, s, alice, "proj", Public)
205+ commitFile(t, g, g.Head(), "", "a.txt", "one\n")
206+ own, _ := s.store.CreateSession(alice.ID)
207+ other, _ := s.store.CreateSession(bob.ID)
208+ for _, bad := range []string{"http://example.com/x.git", "https://127.0.0.1/x.git", "https://user:[email protected]/x.git", "file:///etc", "https://localhost/x"} {
209+ if w := post(h, "/~alice/proj/mirrors", url.Values{"action": {"add"}, "url": {bad}}, own); !strings.Contains(w.Header().Get("Location"), "error") {
210+ t.Errorf("mirror to %s accepted", bad)
211+ }
212+ }
213+ if w := post(h, "/~alice/proj/mirrors", url.Values{"action": {"add"}, "url": {"https://example.com/x.git"}, "token": {"tok"}}, other); w.Code != 403 {
214+ t.Errorf("non-owner added a mirror: %d", w.Code)
215+ }
216+ if len(s.store.Mirrors(repo.ID)) != 0 {
217+ t.Fatal("mirror stored after refusals")
218+ }
219+ if w := post(h, "/~alice/proj/mirrors", url.Values{"action": {"delete"}, "id": {"1"}}, other); w.Code != 403 {
220+ t.Errorf("non-owner touched mirrors: %d", w.Code)
221+ }
222+ if w := post(h, "/~alice/proj/mirrors", url.Values{"action": {"add"}, "url": {"https://example.com/x.git"}, "login": {"bad:name"}}, own); !strings.Contains(w.Header().Get("Location"), "error") {
223+ t.Error("login with a colon accepted")
224+ }
225+ box, _ := seal(s.root, "s3cr3t-token-value")
226+ s.store.AddMirror(repo.ID, "push", "https://example.com/x.git", "oauth2", box, 0)
227+ if body := get(h, "/~alice/proj/settings", own).Body.String(); strings.Contains(body, "s3cr3t") || !strings.Contains(body, "https://example.com/x.git") {
228+ t.Error("settings page leaks a token")
229+ }
230+ if s.store.IsPullMirror(repo.ID) {
231+ t.Error("push mirror counted as pull mirror")
232+ }
233+ s.store.AddMirror(repo.ID, "pull", "https://example.com/y.git", "oauth2", nil, time.Hour)
234+ if err := s.store.AddMirror(repo.ID, "pull", "https://example.com/z.git", "oauth2", nil, time.Hour); err == nil {
235+ t.Error("second pull mirror accepted")
236+ }
237+ if body := get(h, "/~alice/proj/new/"+g.Head()+"/x.txt", own); body.Code != 403 {
238+ t.Errorf("web edit on a pull mirror: %d", body.Code)
239+ }
240+}
241+
180242 func TestModeration(t *testing.T) {
181243 s, h := testServer(t)
182244 alice, _ := s.store.CreateUser("alice", "correct horse")

forge/import.go modified

@@ -108,9 +108,13 @@ func importName(u *url.URL) string {
108108 }
109109
110110 func remoteGit(ctx context.Context, dir, pin, token string, args ...string) ([]byte, error) {
111+ return remoteAuth(ctx, dir, pin, "oauth2", token, args...)
112+}
113+
114+func remoteAuth(ctx context.Context, dir, pin, login, token string, args ...string) ([]byte, error) {
111115 cfg := [][2]string{{"http.curloptResolve", pin}, {"http.followRedirects", "false"}, {"protocol.allow", "never"}, {"protocol.https.allow", "always"}, {"credential.helper", ""}}
112116 if token != "" {
113- cfg = append(cfg, [2]string{"http.extraHeader", "Authorization: Basic " + base64.StdEncoding.EncodeToString([]byte("oauth2:"+token))})
117+ cfg = append(cfg, [2]string{"http.extraHeader", "Authorization: Basic " + base64.StdEncoding.EncodeToString([]byte(login+":"+token))})
114118 }
115119 env := []string{"GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "HOME=/nonexistent", "LC_ALL=C.UTF-8", "PATH=" + os.Getenv("PATH"), "GIT_TERMINAL_PROMPT=0", "GIT_ASKPASS=/bin/false", "GIT_ALLOW_PROTOCOL=https", "GIT_CONFIG_COUNT=" + strconv.Itoa(len(cfg))}
116120 for i, kv := range cfg {
@@ -141,7 +145,7 @@ func (s *Server) importForm(w http.ResponseWriter, r *http.Request) {
141145 if u == nil {
142146 return
143147 }
144- s.render(w, r, "import", page{Title: "Import repository", User: u, Data: map[string]any{"Orgs": s.store.Orgs(u.ID), "Owner": r.URL.Query().Get("owner")}})
148+ s.render(w, r, "import", page{Title: "Import repository", User: u, Data: map[string]any{"Orgs": s.store.Orgs(u.ID), "Owner": r.URL.Query().Get("owner"), "Intervals": []int{1, 4, 8, 24}}})
145149 }
146150
147151 func (s *Server) importRepo(w http.ResponseWriter, r *http.Request) {
@@ -149,7 +153,7 @@ func (s *Server) importRepo(w http.ResponseWriter, r *http.Request) {
149153 if u == nil {
150154 return
151155 }
152- form := map[string]any{"Orgs": s.store.Orgs(u.ID), "Owner": r.FormValue("owner"), "Source": r.FormValue("source"), "Name": r.FormValue("name"), "Description": r.FormValue("description")}
156+ form := map[string]any{"Orgs": s.store.Orgs(u.ID), "Owner": r.FormValue("owner"), "Source": r.FormValue("source"), "Name": r.FormValue("name"), "Description": r.FormValue("description"), "Mirror": r.FormValue("mirror") == "on", "Intervals": []int{1, 4, 8, 24}}
153157 fail := func(msg string) {
154158 w.WriteHeader(400)
155159 s.render(w, r, "import", page{Title: "Import repository", User: u, Error: msg, Data: form})
@@ -197,6 +201,18 @@ func (s *Server) importRepo(w http.ResponseWriter, r *http.Request) {
197201 }
198202 installHook(s.root, dir)
199203 s.uploads.fail("import:" + u.Name)
204+ if r.FormValue("mirror") == "on" {
205+ box, err := seal(s.root, strings.TrimSpace(r.FormValue("token")))
206+ if err == nil {
207+ err = s.store.AddMirror(repo.ID, "pull", src.String(), "oauth2", box, parseInterval(r.FormValue("hours")))
208+ }
209+ if err != nil {
210+ os.RemoveAll(dir)
211+ s.store.DeleteRepo(repo.ID)
212+ fail("Could not set up the mirror")
213+ return
214+ }
215+ }
200216 imports.Lock()
201217 imports.m[repo.ID] = &importState{Source: src.Redacted()}
202218 imports.Unlock()
@@ -221,7 +237,9 @@ func (s *Server) runImport(repo Repo, src, pin, token string) {
221237 for line := range strings.Lines(string(head)) {
222238 if ref, ok := strings.CutPrefix(line, "ref: refs/heads/"); ok {
223239 branch, _, _ := strings.Cut(ref, "\t")
224- Git{Dir: dir}.run(nil, "symbolic-ref", "HEAD", "refs/heads/"+branch)
240+ if validBranch(branch) {
241+ Git{Dir: dir}.run(nil, "symbolic-ref", "HEAD", "refs/heads/"+branch)
242+ }
225243 }
226244 }
227245 if over, used, quota := overQuota(s.root, repo.Owner.Name); over {
@@ -247,6 +265,9 @@ func (s *Server) runImport(repo Repo, src, pin, token string) {
247265 })
248266 return
249267 }
268+ if m, ok := s.store.PullMirror(repo.ID); ok {
269+ s.store.MirrorDone(m.ID, time.Now().Add(m.Interval), "", nil)
270+ }
250271 setImport(repo.ID, func(st *importState) { st.Done = true })
251272 }
252273

forge/mirror.go added

@@ -0,0 +1,467 @@
1+package forge
2+
3+import (
4+ "context"
5+ "crypto/aes"
6+ "crypto/cipher"
7+ "crypto/rand"
8+ "crypto/sha256"
9+ "encoding/hex"
10+ "errors"
11+ "fmt"
12+ "html/template"
13+ "io"
14+ "log"
15+ "net/http"
16+ "net/url"
17+ "os"
18+ "path/filepath"
19+ "strconv"
20+ "strings"
21+ "sync"
22+ "time"
23+)
24+
25+const mirrorSchema = `
26+create table if not exists mirrors (
27+ id integer primary key,
28+ repo integer not null references repos(id) on delete cascade,
29+ kind text not null,
30+ url text not null,
31+ login text not null default '',
32+ secret blob,
33+ interval integer not null default 0,
34+ next integer not null default 0,
35+ synced integer not null default 0,
36+ error text not null default '',
37+ state text not null default '',
38+ created integer not null
39+);
40+create unique index if not exists mirrors_pull on mirrors(repo) where kind = 'pull';
41+create index if not exists mirrors_repo on mirrors(repo);
42+`
43+
44+const (
45+ maxPushMirrors = 5
46+ pushRetry = 10 * time.Minute
47+ pushResync = 24 * time.Hour
48+ defaultInterval = 8 * time.Hour
49+ mirrorTick = 30 * time.Second
50+ mirrorTimeout = 15 * time.Minute
51+)
52+
53+var mirrorIntervals = []time.Duration{time.Hour, 4 * time.Hour, 8 * time.Hour, 24 * time.Hour}
54+
55+type Mirror struct {
56+ ID int64
57+ Repo int64
58+ Kind string
59+ URL string
60+ Login string
61+ secret []byte
62+ Interval time.Duration
63+ Next time.Time
64+ Synced time.Time
65+ Error string
66+ State string
67+}
68+
69+func (m Mirror) HasToken() bool { return len(m.secret) > 0 }
70+
71+func (m Mirror) Source() string {
72+ u, err := url.Parse(m.URL)
73+ if err != nil {
74+ return ""
75+ }
76+ return u.Redacted()
77+}
78+
79+func (m Mirror) Hours() int { return int(m.Interval / time.Hour) }
80+
81+func (m Mirror) Never() bool { return m.Synced.Unix() == 0 }
82+
83+const mirrorColumns = `id, repo, kind, url, login, secret, interval, next, synced, error, state`
84+
85+func scanMirror(scan func(...any) error) (Mirror, error) {
86+ var m Mirror
87+ var interval, next, synced int64
88+ err := scan(&m.ID, &m.Repo, &m.Kind, &m.URL, &m.Login, &m.secret, &interval, &next, &synced, &m.Error, &m.State)
89+ m.Interval = time.Duration(interval) * time.Second
90+ m.Next, m.Synced = time.Unix(next, 0), time.Unix(synced, 0)
91+ return m, err
92+}
93+
94+func (s *Store) Mirrors(repo int64) []Mirror {
95+ rows, err := s.db.Query(`select `+mirrorColumns+` from mirrors where repo = ? order by kind, id`, repo)
96+ if err != nil {
97+ return nil
98+ }
99+ defer rows.Close()
100+ var out []Mirror
101+ for rows.Next() {
102+ if m, err := scanMirror(rows.Scan); err == nil {
103+ out = append(out, m)
104+ }
105+ }
106+ return out
107+}
108+
109+func (s *Store) allMirrors() []Mirror {
110+ rows, err := s.db.Query(`select ` + mirrorColumns + ` from mirrors`)
111+ if err != nil {
112+ return nil
113+ }
114+ defer rows.Close()
115+ var out []Mirror
116+ for rows.Next() {
117+ if m, err := scanMirror(rows.Scan); err == nil {
118+ out = append(out, m)
119+ }
120+ }
121+ return out
122+}
123+
124+func (s *Store) PullMirror(repo int64) (Mirror, bool) {
125+ m, err := scanMirror(s.db.QueryRow(`select `+mirrorColumns+` from mirrors where repo = ? and kind = 'pull'`, repo).Scan)
126+ return m, err == nil
127+}
128+
129+func (s *Store) IsPullMirror(repo int64) bool {
130+ _, ok := s.PullMirror(repo)
131+ return ok
132+}
133+
134+func (s *Store) AddMirror(repo int64, kind, rawURL, login string, secret []byte, interval time.Duration) error {
135+ if kind == "push" {
136+ var n int
137+ s.db.QueryRow(`select count(*) from mirrors where repo = ? and kind = 'push'`, repo).Scan(&n)
138+ if n >= maxPushMirrors {
139+ return fmt.Errorf("A repository can have at most %d push mirrors", maxPushMirrors)
140+ }
141+ }
142+ next := time.Now().Add(interval).Unix()
143+ if kind == "push" {
144+ next = 0
145+ }
146+ _, err := s.db.Exec(`insert into mirrors(repo, kind, url, login, secret, interval, next, created) values (?, ?, ?, ?, ?, ?, ?, ?)`,
147+ repo, kind, rawURL, login, secret, int64(interval/time.Second), next, time.Now().Unix())
148+ if err != nil && strings.Contains(err.Error(), "UNIQUE") {
149+ return errors.New("This repository already mirrors another one")
150+ }
151+ return err
152+}
153+
154+func (s *Store) DeleteMirror(repo, id int64) {
155+ s.db.Exec(`delete from mirrors where id = ? and repo = ?`, id, repo)
156+}
157+
158+func (s *Store) SyncSoon(repo, id int64) {
159+ s.db.Exec(`update mirrors set next = 0 where id = ? and repo = ?`, id, repo)
160+}
161+
162+func (s *Store) SetInterval(repo int64, interval time.Duration) {
163+ s.db.Exec(`update mirrors set interval = ?, next = min(next, ?) where repo = ? and kind = 'pull'`, int64(interval/time.Second), time.Now().Add(interval).Unix(), repo)
164+}
165+
166+func (s *Store) MirrorDone(id int64, next time.Time, state string, err error) {
167+ msg := ""
168+ if err != nil {
169+ msg = err.Error()
170+ if len(msg) > 300 {
171+ msg = msg[:300]
172+ }
173+ }
174+ if err == nil {
175+ s.db.Exec(`update mirrors set next = ?, synced = ?, error = '', state = ? where id = ?`, next.Unix(), time.Now().Unix(), state, id)
176+ return
177+ }
178+ s.db.Exec(`update mirrors set next = ?, error = ? where id = ?`, next.Unix(), msg, id)
179+}
180+
181+func mirrorKey(root string) ([]byte, error) {
182+ keyPath := filepath.Join(root, "mirror.key")
183+ if b, err := os.ReadFile(keyPath); err == nil && len(b) == 32 {
184+ return b, nil
185+ }
186+ key := make([]byte, 32)
187+ if _, err := io.ReadFull(rand.Reader, key); err != nil {
188+ return nil, err
189+ }
190+ f, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
191+ if err != nil {
192+ if b, rerr := os.ReadFile(keyPath); rerr == nil && len(b) == 32 {
193+ return b, nil
194+ }
195+ return nil, err
196+ }
197+ defer f.Close()
198+ if _, err := f.Write(key); err != nil {
199+ return nil, err
200+ }
201+ return key, nil
202+}
203+
204+func seal(root, plain string) ([]byte, error) {
205+ if plain == "" {
206+ return nil, nil
207+ }
208+ key, err := mirrorKey(root)
209+ if err != nil {
210+ return nil, err
211+ }
212+ block, err := aes.NewCipher(key)
213+ if err != nil {
214+ return nil, err
215+ }
216+ gcm, err := cipher.NewGCM(block)
217+ if err != nil {
218+ return nil, err
219+ }
220+ nonce := make([]byte, gcm.NonceSize())
221+ if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
222+ return nil, err
223+ }
224+ return gcm.Seal(nonce, nonce, []byte(plain), nil), nil
225+}
226+
227+func unseal(root string, box []byte) (string, error) {
228+ if len(box) == 0 {
229+ return "", nil
230+ }
231+ key, err := mirrorKey(root)
232+ if err != nil {
233+ return "", err
234+ }
235+ block, err := aes.NewCipher(key)
236+ if err != nil {
237+ return "", err
238+ }
239+ gcm, err := cipher.NewGCM(block)
240+ if err != nil {
241+ return "", err
242+ }
243+ if len(box) < gcm.NonceSize() {
244+ return "", errors.New("stored credential is corrupt")
245+ }
246+ plain, err := gcm.Open(nil, box[:gcm.NonceSize()], box[gcm.NonceSize():], nil)
247+ if err != nil {
248+ return "", errors.New("stored credential cannot be decrypted")
249+ }
250+ return string(plain), nil
251+}
252+
253+func (g Git) refPrint() string {
254+ out, err := g.run(nil, "for-each-ref", "--format=%(objectname) %(refname)", "refs/heads", "refs/tags")
255+ if err != nil {
256+ return ""
257+ }
258+ sum := sha256.Sum256(out)
259+ return hex.EncodeToString(sum[:])
260+}
261+
262+var mirrorRuns = struct {
263+ sync.Mutex
264+ m map[int64]bool
265+}{m: map[int64]bool{}}
266+
267+var mirrorSlots = make(chan struct{}, 2)
268+
269+func (s *Server) mirrorLoop() {
270+ for {
271+ s.mirrorTick()
272+ time.Sleep(mirrorTick)
273+ }
274+}
275+
276+func (s *Server) mirrorTick() {
277+ now := time.Now()
278+ for _, m := range s.store.allMirrors() {
279+ due := !m.Next.After(now)
280+ if m.Kind == "push" && !due && m.Error == "" {
281+ repo, err := s.store.RepoByID(m.Repo)
282+ due = err == nil && (Git{Dir: s.repoDir(repo)}).refPrint() != m.State
283+ }
284+ if !due {
285+ continue
286+ }
287+ mirrorRuns.Lock()
288+ busy := mirrorRuns.m[m.ID]
289+ mirrorRuns.m[m.ID] = true
290+ mirrorRuns.Unlock()
291+ if busy {
292+ continue
293+ }
294+ go func(m Mirror) {
295+ mirrorSlots <- struct{}{}
296+ defer func() {
297+ <-mirrorSlots
298+ mirrorRuns.Lock()
299+ delete(mirrorRuns.m, m.ID)
300+ mirrorRuns.Unlock()
301+ }()
302+ s.runMirror(m)
303+ }(m)
304+ }
305+}
306+
307+func (s *Server) runMirror(m Mirror) {
308+ repo, err := s.store.RepoByID(m.Repo)
309+ if err != nil {
310+ return
311+ }
312+ g := Git{Dir: s.repoDir(repo)}
313+ if m.Kind == "pull" {
314+ err = s.pullMirror(m, repo)
315+ interval := m.Interval
316+ if interval < time.Hour {
317+ interval = defaultInterval
318+ }
319+ s.store.MirrorDone(m.ID, time.Now().Add(interval), "", err)
320+ } else {
321+ state := g.refPrint()
322+ err = s.pushMirror(m, repo)
323+ next := time.Now().Add(pushResync)
324+ if err != nil {
325+ next = time.Now().Add(pushRetry)
326+ }
327+ s.store.MirrorDone(m.ID, next, state, err)
328+ }
329+ if err != nil {
330+ log.Printf("mirror %s %s: %v", m.Kind, repo.Path(), err)
331+ }
332+}
333+
334+func (s *Server) pullMirror(m Mirror, repo Repo) error {
335+ if over, used, quota := overQuota(s.root, repo.Owner.Name); over {
336+ return errors.New(quotaMessage(used, quota))
337+ }
338+ src, pin, err := importSource(m.URL)
339+ if err != nil {
340+ return err
341+ }
342+ token, err := unseal(s.root, m.secret)
343+ if err != nil {
344+ return err
345+ }
346+ ctx, cancel := context.WithTimeout(context.Background(), mirrorTimeout)
347+ defer cancel()
348+ dir := s.repoDir(repo)
349+ head, err := remoteAuth(ctx, dir, pin, m.Login, token, "ls-remote", "--symref", src.String(), "HEAD")
350+ if err != nil {
351+ return err
352+ }
353+ if _, err := remoteAuth(ctx, dir, pin, m.Login, token, "fetch", "--quiet", "--prune", "--no-tags", src.String(), "+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*"); err != nil {
354+ return err
355+ }
356+ for line := range strings.Lines(string(head)) {
357+ if ref, ok := strings.CutPrefix(line, "ref: refs/heads/"); ok {
358+ branch, _, _ := strings.Cut(ref, "\t")
359+ if validBranch(branch) {
360+ Git{Dir: dir}.run(nil, "symbolic-ref", "HEAD", "refs/heads/"+branch)
361+ }
362+ }
363+ }
364+ settleRefs(dir)
365+ if over, used, quota := overQuota(s.root, repo.Owner.Name); over {
366+ return errors.New(quotaMessage(used, quota))
367+ }
368+ return nil
369+}
370+
371+func (s *Server) pushMirror(m Mirror, repo Repo) error {
372+ dst, pin, err := importSource(m.URL)
373+ if err != nil {
374+ return err
375+ }
376+ token, err := unseal(s.root, m.secret)
377+ if err != nil {
378+ return err
379+ }
380+ ctx, cancel := context.WithTimeout(context.Background(), mirrorTimeout)
381+ defer cancel()
382+ _, err = remoteAuth(ctx, s.repoDir(repo), pin, m.Login, token, "push", "--force", "--prune", "--porcelain", dst.String(), "+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*")
383+ return err
384+}
385+
386+func parseInterval(raw string) time.Duration {
387+ n, err := strconv.Atoi(raw)
388+ if err != nil {
389+ return defaultInterval
390+ }
391+ for _, d := range mirrorIntervals {
392+ if time.Duration(n)*time.Hour == d {
393+ return d
394+ }
395+ }
396+ return defaultInterval
397+}
398+
399+func (s *Server) mirrors(w http.ResponseWriter, r *http.Request, repo Repo) {
400+ rp, _, _ := s.repoPage(r, repo, "")
401+ if !rp.IsOwner {
402+ s.fail(w, r, 403, "Not your repository")
403+ return
404+ }
405+ back := "/" + repo.Path() + "/settings#mirrors"
406+ fail := func(msg string) {
407+ http.Redirect(w, r, "/"+repo.Path()+"/settings?error="+template.URLQueryEscaper(msg)+"#mirrors", http.StatusSeeOther)
408+ }
409+ id, _ := strconv.ParseInt(r.FormValue("id"), 10, 64)
410+ switch r.FormValue("action") {
411+ case "add":
412+ if s.uploads.blocked("mirror:" + rp.User.Name) {
413+ fail("Too many mirror changes, try again later")
414+ return
415+ }
416+ dst, _, err := importSource(r.FormValue("url"))
417+ if err != nil {
418+ fail(err.Error())
419+ return
420+ }
421+ login := strings.TrimSpace(r.FormValue("login"))
422+ if login == "" {
423+ login = "oauth2"
424+ }
425+ if !validLogin(login) {
426+ fail("That username is not valid")
427+ return
428+ }
429+ box, err := seal(s.root, strings.TrimSpace(r.FormValue("token")))
430+ if err != nil {
431+ fail("Could not store the token")
432+ return
433+ }
434+ if err := s.store.AddMirror(repo.ID, "push", dst.String(), login, box, 0); err != nil {
435+ fail(err.Error())
436+ return
437+ }
438+ s.uploads.fail("mirror:" + rp.User.Name)
439+ case "delete":
440+ s.store.DeleteMirror(repo.ID, id)
441+ case "sync":
442+ if s.uploads.blocked("mirror:" + rp.User.Name) {
443+ fail("Too many mirror changes, try again later")
444+ return
445+ }
446+ s.uploads.fail("mirror:" + rp.User.Name)
447+ s.store.SyncSoon(repo.ID, id)
448+ case "interval":
449+ s.store.SetInterval(repo.ID, parseInterval(r.FormValue("hours")))
450+ default:
451+ fail("Unknown action")
452+ return
453+ }
454+ http.Redirect(w, r, back, http.StatusSeeOther)
455+}
456+
457+func validLogin(login string) bool {
458+ if len(login) > 100 {
459+ return false
460+ }
461+ for _, c := range login {
462+ if c <= 0x20 || c == 0x7f || c == ':' || c == '@' || c == '/' {
463+ return false
464+ }
465+ }
466+ return true
467+}

forge/pull.go modified

@@ -500,6 +500,10 @@ func (s *Server) createPull(w http.ResponseWriter, r *http.Request, repo Repo) {
500500 retry("This repository is archived")
501501 return
502502 }
503+ if rp.Mirror != "" {
504+ retry("This repository is a mirror and does not take pull requests")
505+ return
506+ }
503507 if s.shunned(repo, user) {
504508 retry("You cannot open pull requests here")
505509 return
@@ -587,6 +591,10 @@ func (s *Server) pull(w http.ResponseWriter, r *http.Request, repo Repo, rest st
587591 s.fail(w, r, 403, "You cannot take part here")
588592 return
589593 }
594+ if action == "merge" && rp.Mirror != "" {
595+ s.fail(w, r, 403, "This repository is a mirror and read only")
596+ return
597+ }
590598 switch action {
591599 case "comment":
592600 if err := s.store.AddComment(pull.ID, *user, r.FormValue("body")); err != nil {

forge/store.go modified

@@ -278,7 +278,7 @@ func openStore(path string, readonly bool) (*Store, error) {
278278 if _, err := db.Exec(schema); err != nil {
279279 return nil, err
280280 }
281- for _, ddl := range []string{pullSchema, collabSchema, extraSchema, adminSchema, inboxSchema, followSchema, renameSchema, moderationSchema} {
281+ for _, ddl := range []string{pullSchema, collabSchema, extraSchema, adminSchema, inboxSchema, followSchema, renameSchema, moderationSchema, mirrorSchema} {
282282 if _, err := db.Exec(ddl); err != nil {
283283 return nil, err
284284 }
@@ -1073,6 +1073,7 @@ func (s *Store) TransferRepo(id int64, owner int64) error {
10731073 return errors.New("That user already has a repository with this name")
10741074 }
10751075 s.db.Exec(`delete from collaborators where repo = ? and user = ?`, id, owner)
1076+ s.db.Exec(`delete from mirrors where repo = ? and kind = 'push'`, id)
10761077 return err
10771078 }
10781079

forge/web.go modified

@@ -1040,6 +1040,8 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) {
10401040 s.manageLabels(w, r, repo)
10411041 case rest == "protection" && r.Method == "POST":
10421042 s.manageProtection(w, r, repo)
1043+ case rest == "mirrors" && r.Method == "POST":
1044+ s.mirrors(w, r, repo)
10431045 case strings.HasPrefix(rest, "edit/"):
10441046 s.edit(w, r, repo, strings.TrimPrefix(rest, "edit/"), false)
10451047 case strings.HasPrefix(rest, "new/"):
@@ -1141,6 +1143,7 @@ type repoPage struct {
11411143 IsOwner bool
11421144 Stars int
11431145 Starred bool
1146+ Mirror string
11441147 }
11451148
11461149 func (s *Server) repoPage(r *http.Request, repo Repo, ref string) (repoPage, Git, error) {
@@ -1150,6 +1153,9 @@ func (s *Server) repoPage(r *http.Request, repo Repo, ref string) (repoPage, Git
11501153 if user != nil {
11511154 rp.Starred = s.store.Starred(user.ID, repo.ID)
11521155 }
1156+ if m, ok := s.store.PullMirror(repo.ID); ok {
1157+ rp.Mirror = m.Source()
1158+ }
11531159 if repo.Parent != 0 {
11541160 if parent, err := s.store.RepoByID(repo.Parent); err == nil && !parent.Private {
11551161 rp.Parent = &parent
@@ -1549,7 +1555,7 @@ func (s *Server) repoSettings(w http.ResponseWriter, r *http.Request, repo Repo)
15491555 rp.Tab = "settings"
15501556 collabs, _ := s.store.Collaborators(repo.ID)
15511557 labels, _ := s.store.Labels(repo.ID)
1552- s.render(w, r, "reposettings", page{Title: repo.Path() + " settings", Error: r.URL.Query().Get("error"), Data: map[string]any{"Page": rp, "Collaborators": collabs, "Labels": labels, "Protections": s.store.Protections(repo.ID), "Pinned": s.store.Pins(repo.Owner.ID)[repo.ID] > 0}})
1558+ s.render(w, r, "reposettings", page{Title: repo.Path() + " settings", Error: r.URL.Query().Get("error"), Data: map[string]any{"Page": rp, "Collaborators": collabs, "Labels": labels, "Protections": s.store.Protections(repo.ID), "Pinned": s.store.Pins(repo.Owner.ID)[repo.ID] > 0, "Mirrors": s.store.Mirrors(repo.ID), "Intervals": []int{1, 4, 8, 24}}})
15531559 }
15541560
15551561 func (s *Server) collaborators(w http.ResponseWriter, r *http.Request, repo Repo) {

web/templates/blob.tmpl modified

@@ -16,7 +16,7 @@
1616 <details class="branchpick"><summary><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>{{$p.Ref}}</summary><div>{{range .Branches}}<a href="/{{$p.Repo.Path}}/tree/{{.Name}}/{{$.Path}}"{{if eq .Name $p.Ref}} class="current"{{end}}>{{.Name}}</a>{{end}}</div></details>
1717 <span class="crumbs"><a href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/">{{$p.Repo.Name}}</a>{{range $i, $c := .Crumbs}}/<a href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/{{join $.Crumbs (inc $i)}}{{if lt (inc $i) (len $.Crumbs)}}/{{end}}">{{$c}}</a>{{end}}</span>
1818 {{with .Entry}}<span class="mode">{{perm .Mode}}</span>{{if eq .Type "blob"}}<span class="muted">{{size .Size}}</span>{{end}}{{end}}
19-<a class="active" href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/{{.Path}}">View</a><a href="/{{$p.Repo.Path}}/log/{{$p.Ref}}?path={{.Path}}">Log</a><a href="/{{$p.Repo.Path}}/blame/{{$p.Ref}}/{{.Path}}">Blame</a><a href="/{{$p.Repo.Path}}/raw/{{$p.Ref}}/{{.Path}}">View raw</a><a href="/{{$p.Repo.Path}}/tree/{{$p.Commit}}/{{.Path}}">Permalink</a>{{if and $p.Owner (not $p.Repo.Archived)}}<a href="/{{$p.Repo.Path}}/edit/{{$p.Ref}}/{{.Path}}">Edit</a><form method="post" action="/{{$p.Repo.Path}}/remove/{{$p.Ref}}/{{.Path}}" class="inlineform"><button class="linkbutton">Delete</button></form>{{end}}
19+<a class="active" href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/{{.Path}}">View</a><a href="/{{$p.Repo.Path}}/log/{{$p.Ref}}?path={{.Path}}">Log</a><a href="/{{$p.Repo.Path}}/blame/{{$p.Ref}}/{{.Path}}">Blame</a><a href="/{{$p.Repo.Path}}/raw/{{$p.Ref}}/{{.Path}}">View raw</a><a href="/{{$p.Repo.Path}}/tree/{{$p.Commit}}/{{.Path}}">Permalink</a>{{if and $p.Owner (not $p.Repo.Archived) (not $p.Mirror)}}<a href="/{{$p.Repo.Path}}/edit/{{$p.Ref}}/{{.Path}}">Edit</a><form method="post" action="/{{$p.Repo.Path}}/remove/{{$p.Ref}}/{{.Path}}" class="inlineform"><button class="linkbutton">Delete</button></form>{{end}}
2020 <span class="spacer"></span>
2121 {{with .LastCommit}}<span class="lastcommit"><a class="hash" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{short .Hash}}</a> {{.Subject}} <span class="muted">{{ago .Time}}</span></span>{{end}}
2222 </div>

web/templates/import.tmpl modified

@@ -4,10 +4,12 @@
44 <form method="post" action="/import" class="twocol">
55 <div>
66 <label>Clone URL <input name="source" type="url" required autofocus placeholder="https://github.com/owner/repo" value="{{.Data.Source}}"></label>
7-<label>Access token <span class="muted">(private sources only)</span><input name="token" type="password" autocomplete="off" placeholder="Personal access token with read access"><small>Used once for this import and never stored.</small></label>
7+<label>Access token <span class="muted">(private sources only)</span><input name="token" type="password" autocomplete="off" placeholder="Personal access token with read access"><small>Used once for this import and never stored, unless the import is a mirror.</small></label>
88 {{with .Data.Orgs}}<label>Owner <select name="owner"><option value="{{$.User.Name}}">~{{$.User.Name}}</option>{{range .}}<option value="{{.Name}}"{{if eq .Name $.Data.Owner}} selected{{end}}>~{{.Name}}</option>{{end}}</select></label>{{end}}
99 <label>Repository name <input name="name" pattern="[a-z0-9][a-z0-9_.-]{0,38}" placeholder="Taken from the URL when left empty" value="{{.Data.Name}}"></label>
1010 <label>Description <input name="description" maxlength="200" value="{{.Data.Description}}"></label>
11+<label class="check"><input type="checkbox" name="mirror"{{if .Data.Mirror}} checked{{end}}> Keep it as a read only mirror<small>Fetches the source again on a schedule. A token given here is stored encrypted for those fetches.</small></label>
12+<label>Sync every <select name="hours">{{range .Data.Intervals}}<option value="{{.}}"{{if eq . 8}} selected{{end}}>{{.}} hours</option>{{end}}</select></label>
1113 <div class="actions"><button class="btn primary">Import repository ▸</button></div>
1214 </div>
1315 <div>

web/templates/layout.tmpl modified

@@ -37,7 +37,7 @@
3737 </header>
3838 {{with .Data}}{{with index . "Page"}}
3939 <div class="repohead">
40-<div class="inner"><div class="titlerow"><h2><a href="/~{{.Repo.Owner.Name}}">~{{.Repo.Owner.Name}}</a>/{{.Repo.Name}}{{if .Repo.Private}} <span class="tag">private</span>{{end}}</h2>{{if .User}}<form method="post" action="/{{.Repo.Path}}/star"><button class="btn{{if .Starred}} starred{{end}}"><svg class="staricon" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 1.6l1.9 4 4.4.5-3.3 3 .9 4.3L8 11.2 4.1 13.4l.9-4.3-3.3-3 4.4-.5z"/></svg>{{if .Starred}}Starred{{else}}Star{{end}} {{.Stars}}</button></form>{{else if .Stars}}<span class="btn static"><svg class="staricon" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 1.6l1.9 4 4.4.5-3.3 3 .9 4.3L8 11.2 4.1 13.4l.9-4.3-3.3-3 4.4-.5z"/></svg>{{.Stars}}</span>{{end}}{{if and .User (not .Owner) (not .Empty)}}<form method="post" action="/{{.Repo.Path}}/fork"><button class="btn"><svg class="forkicon" viewBox="0 0 16 16" aria-hidden="true"><circle cx="4" cy="3" r="1.6"/><circle cx="12" cy="3" r="1.6"/><circle cx="8" cy="13" r="1.6"/><path d="M4 4.6v1.4a2 2 0 0 0 2 2h4a2 2 0 0 0 2-2V4.6M8 8v3.4"/></svg>Fork {{.Repo.Forks}}</button></form>{{else if .Repo.Forks}}<span class="btn static"><svg class="forkicon" viewBox="0 0 16 16" aria-hidden="true"><circle cx="4" cy="3" r="1.6"/><circle cx="12" cy="3" r="1.6"/><circle cx="8" cy="13" r="1.6"/><path d="M4 4.6v1.4a2 2 0 0 0 2 2h4a2 2 0 0 0 2-2V4.6M8 8v3.4"/></svg>{{.Repo.Forks}}</span>{{end}}</div>{{if .Repo.Archived}}<p class="archived">This repository is archived. It is read only.</p>{{end}}{{if and .User (not .IsOwner)}}<a class="muted small reportlink" href="/report?kind=repo&amp;target={{.Repo.Path}}">report</a>{{end}}{{with .Parent}}<p class="desc">forked from <a href="/{{.Path}}">{{.Path}}</a></p>{{end}}{{if and .Repo.Description (eq .Tab "summary")}}<p class="desc">{{.Repo.Description}}</p>{{end}}</div>
40+<div class="inner"><div class="titlerow"><h2><a href="/~{{.Repo.Owner.Name}}">~{{.Repo.Owner.Name}}</a>/{{.Repo.Name}}{{if .Repo.Private}} <span class="tag">private</span>{{end}}</h2>{{if .User}}<form method="post" action="/{{.Repo.Path}}/star"><button class="btn{{if .Starred}} starred{{end}}"><svg class="staricon" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 1.6l1.9 4 4.4.5-3.3 3 .9 4.3L8 11.2 4.1 13.4l.9-4.3-3.3-3 4.4-.5z"/></svg>{{if .Starred}}Starred{{else}}Star{{end}} {{.Stars}}</button></form>{{else if .Stars}}<span class="btn static"><svg class="staricon" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 1.6l1.9 4 4.4.5-3.3 3 .9 4.3L8 11.2 4.1 13.4l.9-4.3-3.3-3 4.4-.5z"/></svg>{{.Stars}}</span>{{end}}{{if and .User (not .Owner) (not .Empty)}}<form method="post" action="/{{.Repo.Path}}/fork"><button class="btn"><svg class="forkicon" viewBox="0 0 16 16" aria-hidden="true"><circle cx="4" cy="3" r="1.6"/><circle cx="12" cy="3" r="1.6"/><circle cx="8" cy="13" r="1.6"/><path d="M4 4.6v1.4a2 2 0 0 0 2 2h4a2 2 0 0 0 2-2V4.6M8 8v3.4"/></svg>Fork {{.Repo.Forks}}</button></form>{{else if .Repo.Forks}}<span class="btn static"><svg class="forkicon" viewBox="0 0 16 16" aria-hidden="true"><circle cx="4" cy="3" r="1.6"/><circle cx="12" cy="3" r="1.6"/><circle cx="8" cy="13" r="1.6"/><path d="M4 4.6v1.4a2 2 0 0 0 2 2h4a2 2 0 0 0 2-2V4.6M8 8v3.4"/></svg>{{.Repo.Forks}}</span>{{end}}</div>{{if .Repo.Archived}}<p class="archived">This repository is archived. It is read only.</p>{{end}}{{if and .User (not .IsOwner)}}<a class="muted small reportlink" href="/report?kind=repo&amp;target={{.Repo.Path}}">report</a>{{end}}{{with .Mirror}}<p class="desc">mirror of <a href="{{.}}" rel="nofollow noopener">{{.}}</a></p>{{end}}{{with .Parent}}<p class="desc">forked from <a href="/{{.Path}}">{{.Path}}</a></p>{{end}}{{if and .Repo.Description (eq .Tab "summary")}}<p class="desc">{{.Repo.Description}}</p>{{end}}</div>
4141 <div class="tabbar"><nav class="tabs inner">
4242 <a href="/{{.Repo.Path}}"{{if eq .Tab "summary"}} class="active"{{end}}>summary</a>
4343 {{if not .Empty}}<a href="/{{.Repo.Path}}/tree/{{.Ref}}/"{{if eq .Tab "tree"}} class="active"{{end}}>tree</a><a href="/{{.Repo.Path}}/log/{{.Ref}}"{{if eq .Tab "log"}} class="active"{{end}}>log</a><a href="/{{.Repo.Path}}/refs"{{if eq .Tab "refs"}} class="active"{{end}}>branches</a><a href="/{{.Repo.Path}}/pulls"{{if eq .Tab "pulls"}} class="active"{{end}}>pull requests{{if .Open}} <span class="count">{{.Open}}</span>{{end}}</a>{{end}}<a href="/{{.Repo.Path}}/issues"{{if eq .Tab "issues"}} class="active"{{end}}>issues{{if .OpenIssues}} <span class="count">{{.OpenIssues}}</span>{{end}}</a>

web/templates/reposettings.tmpl modified

@@ -44,6 +44,20 @@
4444 <label class="check"><input type="checkbox" name="require_pr"> Only accept changes through pull requests</label>
4545 <div class="actions left"><button class="btn primary">Protect ▸</button></div>
4646 </form>
47+<h3 id="mirrors">Mirrors</h3>
48+{{range .Data.Mirrors}}{{if eq .Kind "pull"}}<p>This repository mirrors <code>{{.Source}}</code> and is read only. It syncs every {{.Hours}} hours, last {{if .Never}}never{{else}}{{ago .Synced}}{{end}}.{{with .Error}} <span class="error">Last attempt failed: {{.}}</span>{{end}}</p>
49+<form method="post" action="/{{$p.Repo.Path}}/mirrors" class="inline"><input type="hidden" name="id" value="{{.ID}}"><button class="btn" name="action" value="sync">Sync now ▸</button></form>
50+<form method="post" action="/{{$p.Repo.Path}}/mirrors" class="inline"><input type="hidden" name="action" value="interval"><select name="hours" aria-label="Sync interval">{{$h := .Hours}}{{range $.Data.Intervals}}<option value="{{.}}"{{if eq . $h}} selected{{end}}>every {{.}}h</option>{{end}}</select><button class="btn">Set</button></form>
51+<form method="post" action="/{{$p.Repo.Path}}/mirrors" class="inline"><input type="hidden" name="id" value="{{.ID}}"><button class="btn danger" name="action" value="delete">Stop mirroring</button></form>{{end}}{{end}}
52+<p>Push mirrors copy every branch and tag to another host whenever this repository changes, and once a day regardless. The remote is overwritten, so point them at an empty repository.</p>
53+{{if .Data.Mirrors}}<table class="keys">{{range .Data.Mirrors}}{{if eq .Kind "push"}}<tr><td><code>{{.Source}}</code></td><td class="muted">{{if .Error}}<span class="error">{{.Error}}</span>{{else if .Never}}pending{{else}}synced {{ago .Synced}}{{end}}</td><td><form method="post" action="/{{$p.Repo.Path}}/mirrors" class="inline"><input type="hidden" name="id" value="{{.ID}}"><button class="btn small" name="action" value="sync">Sync</button><button class="btn small danger" name="action" value="delete">Remove</button></form></td></tr>{{end}}{{end}}</table>{{end}}
54+<form method="post" action="/{{$p.Repo.Path}}/mirrors" class="stack">
55+<input type="hidden" name="action" value="add">
56+<label>Remote URL <input name="url" type="url" required placeholder="https://codeberg.org/you/repo.git"></label>
57+<div class="pair"><label>Username <input name="login" autocomplete="off" placeholder="oauth2"></label><label>Token <input name="token" type="password" autocomplete="new-password" placeholder="Token with write access"></label></div>
58+<small>The token is stored encrypted and only sent to that host.</small>
59+<div class="actions left"><button class="btn primary">Add push mirror ▸</button></div>
60+</form>
4761 <h3>Collaborators</h3>
4862 <p>Collaborators can push, read the repository if it is private, and merge pull requests. Only you can change settings or delete it.</p>
4963 {{if .Data.Collaborators}}<table class="keys">{{range .Data.Collaborators}}<tr><td><a href="/~{{.Name}}">~{{.Name}}</a></td><td><form method="post" action="/{{$p.Repo.Path}}/collaborators"><input type="hidden" name="remove" value="{{.ID}}"><button class="btn danger">Remove</button></form></td></tr>{{end}}</table>{{end}}

web/templates/tree.tmpl modified

@@ -19,7 +19,7 @@
1919 <details class="branchpick"><summary><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>{{$p.Ref}}</summary><div>{{range .Branches}}<a href="/{{$p.Repo.Path}}/tree/{{.Name}}/{{$.Path}}"{{if eq .Name $p.Ref}} class="current"{{end}}>{{.Name}}</a>{{end}}</div></details>
2020 <span class="crumbs"><a href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/">{{$p.Repo.Name}}</a>{{range $i, $c := .Crumbs}}/<a href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/{{join $.Crumbs (inc $i)}}{{if lt (inc $i) (len $.Crumbs)}}/{{end}}">{{$c}}</a>{{end}}</span>
2121 {{with .Entry}}<span class="mode">{{perm .Mode}}</span>{{if eq .Type "blob"}}<span class="muted">{{size .Size}}</span>{{end}}{{end}}
22-{{if .Entry}}{{if eq .Entry.Type "blob"}}<a class="active" href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/{{.Path}}">View</a><a href="/{{$p.Repo.Path}}/log/{{$p.Ref}}?path={{.Path}}">Log</a><a href="/{{$p.Repo.Path}}/blame/{{$p.Ref}}/{{.Path}}">Blame</a><a href="/{{$p.Repo.Path}}/raw/{{$p.Ref}}/{{.Path}}">View raw</a><a href="/{{$p.Repo.Path}}/tree/{{$p.Commit}}/{{.Path}}">Permalink</a>{{else}}<a href="/{{$p.Repo.Path}}/log/{{$p.Ref}}?path={{.Path}}">Log</a><a href="/{{$p.Repo.Path}}/tree/{{$p.Commit}}/{{.Path}}/">Permalink</a>{{if and $p.Owner (not $p.Repo.Archived)}}<a href="/{{$p.Repo.Path}}/new/{{$p.Ref}}/{{.Path}}">New file</a>{{end}}{{end}}{{else}}<a href="/{{$p.Repo.Path}}/log/{{$p.Ref}}">Log</a><a href="/{{$p.Repo.Path}}/tree/{{$p.Commit}}/">Permalink</a>{{if and $p.Owner (not $p.Repo.Archived)}}<a href="/{{$p.Repo.Path}}/new/{{$p.Ref}}/">New file</a>{{end}}{{end}}
22+{{if .Entry}}{{if eq .Entry.Type "blob"}}<a class="active" href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/{{.Path}}">View</a><a href="/{{$p.Repo.Path}}/log/{{$p.Ref}}?path={{.Path}}">Log</a><a href="/{{$p.Repo.Path}}/blame/{{$p.Ref}}/{{.Path}}">Blame</a><a href="/{{$p.Repo.Path}}/raw/{{$p.Ref}}/{{.Path}}">View raw</a><a href="/{{$p.Repo.Path}}/tree/{{$p.Commit}}/{{.Path}}">Permalink</a>{{else}}<a href="/{{$p.Repo.Path}}/log/{{$p.Ref}}?path={{.Path}}">Log</a><a href="/{{$p.Repo.Path}}/tree/{{$p.Commit}}/{{.Path}}/">Permalink</a>{{if and $p.Owner (not $p.Repo.Archived) (not $p.Mirror)}}<a href="/{{$p.Repo.Path}}/new/{{$p.Ref}}/{{.Path}}">New file</a>{{end}}{{end}}{{else}}<a href="/{{$p.Repo.Path}}/log/{{$p.Ref}}">Log</a><a href="/{{$p.Repo.Path}}/tree/{{$p.Commit}}/">Permalink</a>{{if and $p.Owner (not $p.Repo.Archived) (not $p.Mirror)}}<a href="/{{$p.Repo.Path}}/new/{{$p.Ref}}/">New file</a>{{end}}{{end}}
2323 <span class="spacer"></span>
2424 <form method="get" action="/{{$p.Repo.Path}}/search" class="filesearch"><input type="hidden" name="ref" value="{{$p.Ref}}"><input name="q" placeholder="Search files" value=""></form>
2525 {{with .LastCommit}}<span class="lastcommit"><a class="hash" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{short .Hash}}</a> {{.Subject}} <span class="muted">{{ago .Time}}</span></span>{{end}}