1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6 "context"
7 "net/url"
8 "strings"
9
10 "connectrpc.com/connect"
11
12 pb "bnema.dev/ermite-api/gen/ermite"
13)
14
15// resolveRef finds the ref a URL path starts with and the path that follows.
16// Ref names may contain "/" ("feature/x"), so the longest prefix of the path
17// that is the short name of a branch or tag wins. A branch wins over a tag of
18// the same name. rest has no leading slash and is "" when the path is the ref
19// alone. ok is false when no ref matches; the caller may then try a commit id.
20func resolveRef(refs []*pb.Ref, p string) (ref *pb.Ref, rest string, ok bool) {
21 names := refsByName(refs)
22 p = strings.Trim(p, "/")
23 for end := len(p); end > 0; end = strings.LastIndex(p[:end], "/") {
24 if r, found := names[p[:end]]; found {
25 return r, strings.TrimPrefix(p[end:], "/"), true
26 }
27 }
28 return nil, "", false
29}
30
31// refsByName indexes the branches and tags by short name. A branch wins over
32// a tag of the same name.
33func refsByName(refs []*pb.Ref) map[string]*pb.Ref {
34 names := make(map[string]*pb.Ref, len(refs))
35 for _, r := range refs {
36 k := r.GetKind()
37 if k != pb.RefKind_REF_KIND_BRANCH && k != pb.RefKind_REF_KIND_TAG {
38 continue
39 }
40 cur, dup := names[r.GetShortName()]
41 if !dup || cur.GetKind() != pb.RefKind_REF_KIND_BRANCH {
42 names[r.GetShortName()] = r
43 }
44 }
45 return names
46}
47
48// escapePath escapes every segment of a slash-separated path for a URL path,
49// keeping the slashes.
50func escapePath(p string) string {
51 segs := strings.Split(p, "/")
52 for i, s := range segs {
53 segs[i] = url.PathEscape(s)
54 }
55 return strings.Join(segs, "/")
56}
57
58// repoHref is the URL of a page of a repository: /<repo>/<kind>/<ref>/<path>.
59// kind, ref and path may be empty.
60func repoHref(repo string, parts ...string) string {
61 var b strings.Builder
62 b.WriteString("/" + url.PathEscape(repo))
63 for _, p := range parts {
64 if p != "" {
65 b.WriteString("/" + escapePath(strings.Trim(p, "/")))
66 }
67 }
68 return b.String()
69}
70
71// target is what the URL of a tree, blob, raw or commits page designates: a
72// commit, the name it was reached by, and a path in its tree.
73type target struct {
74 // rev is what the API is asked for: the id of the commit of a ref, or
75 // the commit id of the URL itself.
76 rev string
77 // name is how the URL spells the ref: a branch or a tag, or the commit id
78 // when detached.
79 name string
80 path string
81 // detached is true when the URL names a commit and not a ref.
82 detached bool
83 // commit is the full id of the commit when the page has learnt it from
84 // the API and rev is an abbreviation; "" otherwise.
85 commit string
86}
87
88// commitID is the full id of the commit of the target when it is known, and
89// rev otherwise.
90func (t target) commitID() string {
91 if t.commit != "" {
92 return t.commit
93 }
94 return t.rev
95}
96
97// resolveCommit sets the full id of a target named by an abbreviated commit id,
98// with one ResolveRevision. The page renders without it (it only lacks the file
99// finder), so a failure is not one.
100func (s *Server) resolveCommit(ctx context.Context, repo string, t *target) {
101 if !t.detached || isFullID(t.rev) {
102 return
103 }
104 res, err := s.cfg.Git.ResolveRevision(ctx, connect.NewRequest(&pb.ResolveRevisionRequest{Repo: repo, Revision: t.rev}))
105 if err != nil {
106 return
107 }
108 t.commit = res.Msg.GetCommitId()
109}
110
111// locate reads "<ref>[/<path>]" or "<commit>[/<path>]". The ref is the
112// longest prefix of the path that is the short name of a branch or a tag
113// (names may contain "/"); a branch wins over a tag of the same name. An
114// empty rest designates the head of the repository. Whether the commit or
115// the path exists is for the API to say.
116func (d *repoData) locate(rest string) (target, bool) {
117 rest = strings.Trim(rest, "/")
118 if rest == "" {
119 if d.head == nil {
120 return target{}, false
121 }
122 return target{rev: d.head.GetCommitId(), name: d.head.GetShortName()}, true
123 }
124 if ref, p, ok := resolveRef(d.allRefs(), rest); ok && ref.GetCommitId() != "" {
125 return target{rev: ref.GetCommitId(), name: ref.GetShortName(), path: p}, true
126 }
127 first, p, _ := strings.Cut(rest, "/")
128 if isCommitID(first) {
129 return target{rev: first, name: first, path: p, detached: true}, true
130 }
131 return target{}, false
132}
133
134// isCommitID reports whether s can be a commit id or an abbreviation of one
135// (7 to 40 lowercase hexadecimal digits).
136func isCommitID(s string) bool {
137 if len(s) < 7 || len(s) > 40 {
138 return false
139 }
140 for _, c := range []byte(s) {
141 if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
142 return false
143 }
144 }
145 return true
146}
147
148// isBranchHead reports whether t is a branch at its tip, as opposed to a tag
149// or a commit id.
150func (d *repoData) isBranchHead(t target) bool {
151 if t.detached || !isFullID(t.rev) {
152 return false
153 }
154 for _, b := range d.branches {
155 if b.GetShortName() == t.name && b.GetCommitId() == t.rev {
156 return true
157 }
158 }
159 return false
160}
161
162// pinRef points the ref switcher of the layout at what a page shows: a
163// branch or a tag, which is then the current one of the lists, or a commit,
164// which makes none current. It points the file finder at the commit too.
165func pinRef(l *Layout, t target) {
166 if t.detached {
167 l.Ref, l.RefPath, l.Short, l.SHA = "detached", "", short(t.rev), ""
168 if len(t.rev) == 40 {
169 l.SHA = t.rev
170 }
171 l.setFinder(t.commitID(), t.rev)
172 for i := range l.Heads {
173 l.Heads[i].Current = false
174 }
175 for i := range l.Tags {
176 l.Tags[i].Current = false
177 }
178 return
179 }
180 l.Ref, l.RefPath, l.SHA, l.Short = t.name, t.name, t.rev, short(t.rev)
181 l.setFinder(t.rev, t.name)
182 for i := range l.Heads {
183 l.Heads[i].Current = l.Heads[i].Name == t.name
184 }
185 for i := range l.Tags {
186 l.Tags[i].Current = l.Tags[i].Name == t.name && !hasName(l.Heads, t.name)
187 }
188}
189
190// setFinder points the file finder at a commit: the list of its paths when sha
191// is a full commit id, and the blob pages of ref (a ref name or a commit id)
192// its results open. Without a full id there is nothing to search and the
193// page has no finder data.
194func (l *Layout) setFinder(sha, ref string) {
195 l.PathsURL, l.BlobBase = "", ""
196 if l.Repo == "" || ref == "" || !isFullID(sha) {
197 return
198 }
199 l.PathsURL = repoHref(l.Repo, "paths", sha)
200 l.BlobBase = repoHref(l.Repo, "blob", ref)
201}
202
203func hasName(items []RefItem, name string) bool {
204 for _, it := range items {
205 if it.Name == name {
206 return true
207 }
208 }
209 return false
210}
211
212// pathCrumbs is the path in the header: repo, kind ("tree", "blob" or
213// "commits"), ref, then the segments of the path. The directories link to
214// their tree; the last segment links to the file for a blob, and to nothing
215// for a history.
216func pathCrumbs(repo, kind string, t target) []Crumb {
217 home := repoHref(repo, "tree", t.name)
218 if kind == "commits" {
219 home = repoHref(repo, "commits", t.name)
220 }
221 cs := []Crumb{
222 {Label: repo, Href: repoHref(repo)},
223 {Label: kind, Href: home},
224 {Label: t.name, Href: home, Current: t.path == ""},
225 }
226 if t.path == "" {
227 return cs
228 }
229 segs := strings.Split(t.path, "/")
230 for i, seg := range segs {
231 last := i == len(segs)-1
232 c := Crumb{Label: visibleText(seg), Current: last}
233 switch {
234 case !last || kind == "tree":
235 c.Href = repoHref(repo, "tree", t.name, strings.Join(segs[:i+1], "/"))
236 case kind == "blob":
237 c.Href = repoHref(repo, "blob", t.name, t.path)
238 }
239 cs = append(cs, c)
240 }
241 return cs
242}