1package tinyclient23import (4 "context"5 "encoding/hex"6 "net/url"7 "strings"8)910// NameDirectory resolves relay member names for URLs. It is optional; a11// backend without it writes and reads hex pubkeys only.12type NameDirectory interface {13 // NameFor returns the member name for a pubkey, or "" when it has none.14 NameFor(ctx context.Context, pubkey string) string15 // PubKeyFor returns the pubkey behind a member name, or "" when unknown.16 PubKeyFor(ctx context.Context, name string) string17}1819// person turns a URL segment into a pubkey. It accepts a member name, an20// npub and a hex pubkey. An unknown name resolves to "".21func (a *App) person(ctx context.Context, segment string) string {22 segment = strings.TrimSpace(segment)23 if segment == "" {24 return ""25 }26 if eventIDPattern.MatchString(segment) {27 return strings.ToLower(segment)28 }29 if strings.HasPrefix(segment, "npub1") {30 if hrp, data, ok := bech32Decode(segment); ok && hrp == "npub" && len(data) == 32 {31 return hex.EncodeToString(data)32 }33 return ""34 }35 if directory, ok := a.backend.(NameDirectory); ok {36 return directory.PubKeyFor(ctx, segment)37 }38 // Without a directory the segment is passed through as written; the39 // backend decides whether it names anyone.40 return segment41}4243// handle is the segment a pubkey is written as: the member name when the44// relay knows one, else the hex pubkey.45func (a *App) handle(ctx context.Context, pubkey string) string {46 if directory, ok := a.backend.(NameDirectory); ok && pubkey != "" {47 if name := directory.NameFor(ctx, pubkey); name != "" {48 return name49 }50 }51 return pubkey52}5354// repoRoute is one parsed /repos/{owner}/{repo}/... path. Segments stay as55// written; the owner is resolved to a pubkey by the caller.56type repoRoute struct {57 owner, repo, view, path, id string58 ok bool59}6061var repoViews = map[string]bool{"tree": true, "file": true, "history": true, "commit": true, "activity": true, "issues": true, "prs": true}6263func parseRepoRoute(path string) repoRoute {64 rest, ok := strings.CutPrefix(path, "/repos/")65 if !ok || rest == "" {66 return repoRoute{}67 }68 parts := strings.SplitN(rest, "/", 4)69 if len(parts) < 2 || parts[0] == "" || parts[1] == "" {70 return repoRoute{}71 }72 route := repoRoute{owner: unescapeSegment(parts[0]), repo: unescapeSegment(parts[1]), view: "home", ok: true}73 if len(parts) == 2 {74 return route75 }76 view := parts[2]77 if !repoViews[view] {78 return repoRoute{}79 }80 route.view = view81 tail := ""82 if len(parts) == 4 {83 tail = parts[3]84 }85 switch view {86 case "tree":87 route.path = unescapePath(tail)88 case "file":89 if tail == "" {90 return repoRoute{}91 }92 route.path = unescapePath(tail)93 case "commit":94 if tail == "" {95 return repoRoute{}96 }97 route.id = unescapeSegment(tail)98 case "issues", "prs":99 if tail != "" {100 if !eventIDPattern.MatchString(tail) {101 return repoRoute{}102 }103 route.id = tail104 route.view = map[string]string{"issues": "issue", "prs": "pr"}[view]105 }106 default:107 if tail != "" {108 return repoRoute{}109 }110 }111 return route112}113114func unescapeSegment(value string) string {115 if decoded, err := url.PathUnescape(value); err == nil {116 return decoded117 }118 return value119}120121func unescapePath(value string) string {122 parts := strings.Split(value, "/")123 for i, part := range parts {124 parts[i] = unescapeSegment(part)125 }126 return strings.Join(parts, "/")127}128129func escapePath(value string) string {130 parts := strings.Split(value, "/")131 for i, part := range parts {132 parts[i] = url.PathEscape(part)133 }134 return strings.Join(parts, "/")135}136137// repoPath writes a repository page address. owner is the handle to show,138// view one of the page views (issue and pr mean one item named by id), and139// path or id the item within the view. Query keeps only transient state.140func repoPath(owner, repo, view, item string, query url.Values) string {141 base := "/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(repo)142 switch view {143 case "", "home":144 case "tree":145 base += "/tree"146 if item != "" {147 base += "/" + escapePath(item)148 }149 case "file":150 base += "/file/" + escapePath(item)151 case "commit":152 base += "/commit/" + url.PathEscape(item)153 case "issue":154 base += "/issues/" + url.PathEscape(item)155 case "pr":156 base += "/prs/" + url.PathEscape(item)157 default:158 base += "/" + view159 }160 keep := url.Values{}161 for _, key := range []string{"ref", "offset", "cursor", "q", "status", "label", "download"} {162 if value := query.Get(key); value != "" {163 keep.Set(key, value)164 }165 }166 if len(keep) == 0 {167 return base168 }169 return base + "?" + keep.Encode()170}171172// routeKeys are query values the page derives from its path. Links built173// from the current query drop them so they are never written back out.174var routeKeys = map[string]bool{"owner": true, "handle": true, "repo": true, "view": true, "id": true, "hash": true, "agent": true, "merge": true, "d": true, "address": true}175176func transientQuery(query url.Values) url.Values {177 values := make(url.Values, len(query))178 for name, items := range query {179 if routeKeys[name] {180 continue181 }182 values[name] = append([]string(nil), items...)183 }184 return values185}186187// filesView names the collection behind /files/{view}, or "" when the path188// is not one.189func filesView(path string) string {190 view, ok := strings.CutPrefix(path, "/files/")191 if !ok {192 return ""193 }194 switch view {195 case "sites", "rooms", "storage":196 return view197 }198 return ""199}200201// fileHash is the hash behind /file/{hash}, or "".202func fileHash(path string) string {203 hash, ok := strings.CutPrefix(path, "/file/")204 if !ok || hash == "" || strings.Contains(hash, "/") {205 return ""206 }207 return unescapeSegment(hash)208}209210// approvalID is the request behind /approvals/{id}, or "".211func approvalID(path string) string {212 id, ok := strings.CutPrefix(path, "/approvals/")213 if !ok || !eventIDPattern.MatchString(id) {214 return ""215 }216 return strings.ToLower(id)217}218219// addHandles writes a "handle" beside the pubkey field of each row so220// templates can link people by name.221func (a *App) addHandles(ctx context.Context, rows []any, field string) {222 for _, row := range rows {223 if item, _ := row.(map[string]any); item != nil {224 if pubkey := plainString(item[field]); pubkey != "" {225 item["handle"] = a.handle(ctx, pubkey)226 }227 }228 }229}