feat(mirror): add pull and push mirrors with sealed credentials
14 files changed, +603 −11
- README.md modified +5 −0
- forge/cli.go modified +3 −1
- forge/edit.go modified +4 −0
- forge/feature_test.go modified +62 −0
- forge/import.go modified +25 −4
- forge/mirror.go added +467 −0
- forge/pull.go modified +8 −0
- forge/store.go modified +2 −1
- forge/web.go modified +7 −1
- web/templates/blob.tmpl modified +1 −1
- web/templates/import.tmpl modified +3 −1
- web/templates/layout.tmpl modified +1 −1
- web/templates/reposettings.tmpl modified +14 −0
- web/templates/tree.tmpl modified +1 −1
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 | ||
| 22 | 22 | - Organisations, collaborators, issues, labels, stars, forks, branch protection and per owner disk quotas enforced during the push |
| 23 | 23 | - Profiles with a pinned repository list, an activity graph, followers and a README from your `~you/you` repository |
| 24 | 24 | - 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 | |
| 25 | 26 | - Static sites served from `<user>.<host>`, built from a `pages` repository or the `pages` branch of any public repository |
| 26 | 27 | - Reports to admins and per account blocking, with hourly limits on new issues, pull requests and repositories |
| 27 | 28 | - 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 | ||
| 53 | 54 | |
| 54 | 55 | 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. |
| 55 | 56 | |
| 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 | + | |
| 56 | 61 | ## Development |
| 57 | 62 | |
| 58 | 63 | ``` |
forge/cli.go modified
| @@ -234,9 +234,11 @@ func serve(root string, args []string) { | ||
| 234 | 234 | trustedProxies = append(trustedProxies, n) |
| 235 | 235 | } |
| 236 | 236 | store := mustStore(root) |
| 237 | + site := newServer(store, root, *host, *name, *sshUser) | |
| 238 | + go site.mirrorLoop() | |
| 237 | 239 | srv := &http.Server{ |
| 238 | 240 | Addr: *listen, |
| 239 | - Handler: newServer(store, root, *host, *name, *sshUser).handler(), | |
| 241 | + Handler: site.handler(), | |
| 240 | 242 | ReadHeaderTimeout: 10 * time.Second, |
| 241 | 243 | IdleTimeout: 2 * time.Minute, |
| 242 | 244 | MaxHeaderBytes: 64 << 10, |
forge/edit.go modified
| @@ -150,6 +150,10 @@ func (s *Server) editAllowed(w http.ResponseWriter, r *http.Request, repo Repo, | ||
| 150 | 150 | s.fail(w, r, 403, "This repository is archived") |
| 151 | 151 | return rp, g, false |
| 152 | 152 | } |
| 153 | + if rp.Mirror != "" { | |
| 154 | + s.fail(w, r, 403, "This repository is a mirror and read only") | |
| 155 | + return rp, g, false | |
| 156 | + } | |
| 153 | 157 | if _, err := g.Resolve("refs/heads/" + branch); err != nil || !validBranch(branch) { |
| 154 | 158 | s.fail(w, r, 404, "Edits need a branch, not a tag or commit") |
| 155 | 159 | return rp, g, false |
forge/feature_test.go modified
| @@ -1,9 +1,11 @@ | ||
| 1 | 1 | package forge |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | + "bytes" | |
| 4 | 5 | "net/http" |
| 5 | 6 | "net/http/httptest" |
| 6 | 7 | "net/url" |
| 8 | + "os" | |
| 7 | 9 | "os/exec" |
| 8 | 10 | "path/filepath" |
| 9 | 11 | "strconv" |
| @@ -177,6 +179,66 @@ func TestReservedNames(t *testing.T) { | ||
| 177 | 179 | } |
| 178 | 180 | } |
| 179 | 181 | |
| 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 | + | |
| 180 | 242 | func TestModeration(t *testing.T) { |
| 181 | 243 | s, h := testServer(t) |
| 182 | 244 | alice, _ := s.store.CreateUser("alice", "correct horse") |
forge/import.go modified
| @@ -108,9 +108,13 @@ func importName(u *url.URL) string { | ||
| 108 | 108 | } |
| 109 | 109 | |
| 110 | 110 | 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) { | |
| 111 | 115 | cfg := [][2]string{{"http.curloptResolve", pin}, {"http.followRedirects", "false"}, {"protocol.allow", "never"}, {"protocol.https.allow", "always"}, {"credential.helper", ""}} |
| 112 | 116 | 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))}) | |
| 114 | 118 | } |
| 115 | 119 | 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))} |
| 116 | 120 | for i, kv := range cfg { |
| @@ -141,7 +145,7 @@ func (s *Server) importForm(w http.ResponseWriter, r *http.Request) { | ||
| 141 | 145 | if u == nil { |
| 142 | 146 | return |
| 143 | 147 | } |
| 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}}}) | |
| 145 | 149 | } |
| 146 | 150 | |
| 147 | 151 | func (s *Server) importRepo(w http.ResponseWriter, r *http.Request) { |
| @@ -149,7 +153,7 @@ func (s *Server) importRepo(w http.ResponseWriter, r *http.Request) { | ||
| 149 | 153 | if u == nil { |
| 150 | 154 | return |
| 151 | 155 | } |
| 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}} | |
| 153 | 157 | fail := func(msg string) { |
| 154 | 158 | w.WriteHeader(400) |
| 155 | 159 | 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) { | ||
| 197 | 201 | } |
| 198 | 202 | installHook(s.root, dir) |
| 199 | 203 | 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 | + } | |
| 200 | 216 | imports.Lock() |
| 201 | 217 | imports.m[repo.ID] = &importState{Source: src.Redacted()} |
| 202 | 218 | imports.Unlock() |
| @@ -221,7 +237,9 @@ func (s *Server) runImport(repo Repo, src, pin, token string) { | ||
| 221 | 237 | for line := range strings.Lines(string(head)) { |
| 222 | 238 | if ref, ok := strings.CutPrefix(line, "ref: refs/heads/"); ok { |
| 223 | 239 | 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 | + } | |
| 225 | 243 | } |
| 226 | 244 | } |
| 227 | 245 | 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) { | ||
| 247 | 265 | }) |
| 248 | 266 | return |
| 249 | 267 | } |
| 268 | + if m, ok := s.store.PullMirror(repo.ID); ok { | |
| 269 | + s.store.MirrorDone(m.ID, time.Now().Add(m.Interval), "", nil) | |
| 270 | + } | |
| 250 | 271 | setImport(repo.ID, func(st *importState) { st.Done = true }) |
| 251 | 272 | } |
| 252 | 273 | |
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) { | ||
| 500 | 500 | retry("This repository is archived") |
| 501 | 501 | return |
| 502 | 502 | } |
| 503 | + if rp.Mirror != "" { | |
| 504 | + retry("This repository is a mirror and does not take pull requests") | |
| 505 | + return | |
| 506 | + } | |
| 503 | 507 | if s.shunned(repo, user) { |
| 504 | 508 | retry("You cannot open pull requests here") |
| 505 | 509 | return |
| @@ -587,6 +591,10 @@ func (s *Server) pull(w http.ResponseWriter, r *http.Request, repo Repo, rest st | ||
| 587 | 591 | s.fail(w, r, 403, "You cannot take part here") |
| 588 | 592 | return |
| 589 | 593 | } |
| 594 | + if action == "merge" && rp.Mirror != "" { | |
| 595 | + s.fail(w, r, 403, "This repository is a mirror and read only") | |
| 596 | + return | |
| 597 | + } | |
| 590 | 598 | switch action { |
| 591 | 599 | case "comment": |
| 592 | 600 | 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) { | ||
| 278 | 278 | if _, err := db.Exec(schema); err != nil { |
| 279 | 279 | return nil, err |
| 280 | 280 | } |
| 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} { | |
| 282 | 282 | if _, err := db.Exec(ddl); err != nil { |
| 283 | 283 | return nil, err |
| 284 | 284 | } |
| @@ -1073,6 +1073,7 @@ func (s *Store) TransferRepo(id int64, owner int64) error { | ||
| 1073 | 1073 | return errors.New("That user already has a repository with this name") |
| 1074 | 1074 | } |
| 1075 | 1075 | 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) | |
| 1076 | 1077 | return err |
| 1077 | 1078 | } |
| 1078 | 1079 | |
forge/web.go modified
| @@ -1040,6 +1040,8 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) { | ||
| 1040 | 1040 | s.manageLabels(w, r, repo) |
| 1041 | 1041 | case rest == "protection" && r.Method == "POST": |
| 1042 | 1042 | s.manageProtection(w, r, repo) |
| 1043 | + case rest == "mirrors" && r.Method == "POST": | |
| 1044 | + s.mirrors(w, r, repo) | |
| 1043 | 1045 | case strings.HasPrefix(rest, "edit/"): |
| 1044 | 1046 | s.edit(w, r, repo, strings.TrimPrefix(rest, "edit/"), false) |
| 1045 | 1047 | case strings.HasPrefix(rest, "new/"): |
| @@ -1141,6 +1143,7 @@ type repoPage struct { | ||
| 1141 | 1143 | IsOwner bool |
| 1142 | 1144 | Stars int |
| 1143 | 1145 | Starred bool |
| 1146 | + Mirror string | |
| 1144 | 1147 | } |
| 1145 | 1148 | |
| 1146 | 1149 | 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 | ||
| 1150 | 1153 | if user != nil { |
| 1151 | 1154 | rp.Starred = s.store.Starred(user.ID, repo.ID) |
| 1152 | 1155 | } |
| 1156 | + if m, ok := s.store.PullMirror(repo.ID); ok { | |
| 1157 | + rp.Mirror = m.Source() | |
| 1158 | + } | |
| 1153 | 1159 | if repo.Parent != 0 { |
| 1154 | 1160 | if parent, err := s.store.RepoByID(repo.Parent); err == nil && !parent.Private { |
| 1155 | 1161 | rp.Parent = &parent |
| @@ -1549,7 +1555,7 @@ func (s *Server) repoSettings(w http.ResponseWriter, r *http.Request, repo Repo) | ||
| 1549 | 1555 | rp.Tab = "settings" |
| 1550 | 1556 | collabs, _ := s.store.Collaborators(repo.ID) |
| 1551 | 1557 | 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}}}) | |
| 1553 | 1559 | } |
| 1554 | 1560 | |
| 1555 | 1561 | func (s *Server) collaborators(w http.ResponseWriter, r *http.Request, repo Repo) { |
web/templates/blob.tmpl modified
| @@ -16,7 +16,7 @@ | ||
| 16 | 16 | <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> |
| 17 | 17 | <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> |
| 18 | 18 | {{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}} | |
| 20 | 20 | <span class="spacer"></span> |
| 21 | 21 | {{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}} |
| 22 | 22 | </div> |
web/templates/import.tmpl modified
| @@ -4,10 +4,12 @@ | ||
| 4 | 4 | <form method="post" action="/import" class="twocol"> |
| 5 | 5 | <div> |
| 6 | 6 | <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> | |
| 8 | 8 | {{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}} |
| 9 | 9 | <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> |
| 10 | 10 | <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> | |
| 11 | 13 | <div class="actions"><button class="btn primary">Import repository ▸</button></div> |
| 12 | 14 | </div> |
| 13 | 15 | <div> |
web/templates/layout.tmpl modified
| @@ -37,7 +37,7 @@ | ||
| 37 | 37 | </header> |
| 38 | 38 | {{with .Data}}{{with index . "Page"}} |
| 39 | 39 | <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&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&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> | |
| 41 | 41 | <div class="tabbar"><nav class="tabs inner"> |
| 42 | 42 | <a href="/{{.Repo.Path}}"{{if eq .Tab "summary"}} class="active"{{end}}>summary</a> |
| 43 | 43 | {{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 @@ | ||
| 44 | 44 | <label class="check"><input type="checkbox" name="require_pr"> Only accept changes through pull requests</label> |
| 45 | 45 | <div class="actions left"><button class="btn primary">Protect ▸</button></div> |
| 46 | 46 | </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> | |
| 47 | 61 | <h3>Collaborators</h3> |
| 48 | 62 | <p>Collaborators can push, read the repository if it is private, and merge pull requests. Only you can change settings or delete it.</p> |
| 49 | 63 | {{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 @@ | ||
| 19 | 19 | <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> |
| 20 | 20 | <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> |
| 21 | 21 | {{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}} | |
| 23 | 23 | <span class="spacer"></span> |
| 24 | 24 | <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> |
| 25 | 25 | {{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}} |