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