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