1package tinyclient23import (4 "crypto/sha256"5 "embed"6 "encoding/hex"7 "encoding/json"8 "fmt"9 "html/template"10 "net/url"11 "sort"12 "strings"13 "sync"14 "time"15)1617// templateFS holds every page template. page.html owns the shell and the18// shared partials; the other files each define one group of tabs.19//20//go:embed page.html public.html manage.html browse.html repo.html collaboration.html wiki.html rooms.html social.html chat.html chat-activity.html21var templateFS embed.FS2223// styleCSS is inlined into every page so the UI needs no extra request and24// works unchanged under a tenant path prefix. It styles ids, elements and the25// custom element names only; templates never carry class attributes.26//27//go:embed style.css28var styleCSS string2930// bridgeJS is the signer bridge and componentsJS defines the custom31// elements. Both are inlined at the end of the body in that order.32//33//go:embed bridge.js34var bridgeJS string3536//go:embed tiny.js37var tinyJS string3839//go:embed components.js40var componentsJS string4142//go:embed rooms.js43var roomsJS string4445//go:embed chat-presence.js46var chatPresenceJS string4748//go:embed chat-activity.js49var chatActivityJS string5051//go:embed grant-request.js52var grantRequestJS string5354//go:embed chat.js55var chatJS string5657//go:embed chat-files.js58var chatFilesJS string5960//go:embed direct-messages.js61var directMessagesJS string6263// nostr-name.js is the vendored <nostr-name> element with the relay-local64// profile loader; rebuild it with npm run build:nostr-name.65//66//go:embed nostr-name.js67var nostrNameJS string6869//go:embed blossom-encryption.js70var blossomEncryptionJS string7172//go:embed blossom-manifests.js73var blossomManifestsJS string7475//go:embed file-messages.js76var fileMessagesJS string7778//go:embed private-services.js79var privateServicesJS string8081//go:embed blossom-upload.js82var blossomUploadJS string8384//go:embed file-workspace.js85var fileWorkspaceJS string8687//go:embed file-catalog.js88var fileCatalogJS string8990// Installable app assets. The manifest is a template: NAME and BASE are91// replaced per request so tenant prefixes and relay names stay correct.92//93//go:embed manifest.webmanifest94var manifestTemplate string9596//go:embed icon-192.png97var icon192PNG []byte9899//go:embed icon-512.png100var icon512PNG []byte101102//go:embed icon-maskable-512.png103var iconMaskablePNG []byte104105//go:embed apple-touch-icon.png106var appleTouchIconPNG []byte107108//go:embed badge-96.png109var badgePNG []byte110111//go:embed screenshot-narrow.png112var screenshotNarrowPNG []byte113114//go:embed screenshot-wide.png115var screenshotWidePNG []byte116117//go:embed sw.js118var serviceWorkerJS []byte119120//go:embed icon.svg121var iconSVG []byte122123//go:embed icon-mono.svg124var iconMonoSVG []byte125126// navItem is one rail entry. Tab matches PageData.Tab for the active state;127// Group separates related destinations with a small gap.128type navItem struct {129 Label, Href, Tab string130 Group int131}132133// relayNav groups discovery, conversations, shared work and administration.134var relayNav = []navItem{135 {"/home", "/", "home", 0},136 {"/search", "/search", "search", 0},137 {"/social", "/social", "social", 1},138 {"/chat", "/chat", "chat", 1},139 {"/repos", "/repos", "repos", 2},140 {"/wiki", "/wiki", "wiki", 2},141 {"/files", "/files", "files", 2},142 {"/sites", "/sites", "sites", 2},143 {"/approvals", "/approvals", "approvals", 3},144 {"/manage", "/manage/people", "manage", 3},145}146147var manageNav = []navItem{{"/people", "/manage/people", "people", 0}, {"/agents", "/manage/agents", "agents", 0}, {"/moderation", "/manage/moderation", "moderation", 1}, {"/rules", "/manage/rules", "rules", 1}, {"/identity", "/manage/identity", "identity", 2}, {"/connect", "/manage/connect", "connect", 2}, {"/owner", "/manage/owner", "owner", 2}, {"/sync", "/manage/sync", "sync", 3}, {"/data", "/manage/data", "data", 3}, {"/views", "/manage/views", "views", 3}, {"/health", "/manage/health", "health", 4}}148149// navGroups splits a rail list into its groups, in order.150func navGroups(items []navItem) [][]navItem {151 var groups [][]navItem152 for _, item := range items {153 if len(groups) == 0 || groups[len(groups)-1][0].Group != item.Group {154 groups = append(groups, nil)155 }156 groups[len(groups)-1] = append(groups[len(groups)-1], item)157 }158 return groups159}160161// railKind picks the sitemap for a tab: one repository, the rooms,162// management, or the relay.163func railKind(tab string) string {164 if tab == "repo" {165 return "repo"166 }167 if tab == "chat" || tab == "direct" || tab == "rooms" || tab == "room" || tab == "thread" {168 return "rooms"169 }170 for _, item := range manageNav {171 if item.Tab == tab {172 return "manage"173 }174 }175 return "relay"176}177178// promptPath renders the footer prompt: the path after the relay name, with179// repository pages spelled out as repos/<name>/<view>.180func promptPath(path string, query url.Values) string {181 path = strings.Trim(path, "/")182 switch {183 case path == "":184 return "home"185 case path == "repo" && query.Get("repo") != "":186 return "repos/" + query.Get("repo") + "/" + repoView(query)187 case path == "file" && (query.Get("sha") != "" || query.Get("hash") != ""):188 return "files/" + shortID(query.Get("sha")+query.Get("hash"))189 }190 if room := roomRoute("/" + path); room.tab == "thread" {191 return "rooms/" + room.id + "/thread/" + shortID(room.event)192 }193 if strings.HasPrefix(path, "wiki/") {194 return "wiki/" + wikiPageName("/"+path)195 }196 return path197}198199// pageAddress is the address the crumb copies: the relay's public URL plus200// the page's canonical path, so a link copied on a tailnet or LAN address201// still opens for anyone. Wiki names are normalized the way the relay202// resolves them; repositories and files keep the query that names them.203func pageAddress(base, path string, query url.Values) string {204 path = "/" + strings.Trim(path, "/")205 if path == "/" {206 path = ""207 }208 if strings.HasPrefix(path, "/wiki/") {209 path = "/wiki/" + url.PathEscape(wikiPageName(path))210 }211 keep := url.Values{}212 for _, key := range []string{"owner", "repo", "view", "path", "ref", "sha", "hash", "q", "id", "author", "version", "merge", "address", "kind"} {213 if value := query.Get(key); value != "" {214 keep.Set(key, value)215 }216 }217 address := strings.TrimSuffix(base, "/") + path218 if len(keep) > 0 {219 address += "?" + keep.Encode()220 }221 return address222}223224// wsURL turns the relay's public http(s) URL into its websocket form.225func wsURL(base string) string {226 switch {227 case strings.HasPrefix(base, "https://"):228 return "wss://" + strings.TrimPrefix(base, "https://")229 case strings.HasPrefix(base, "http://"):230 return "ws://" + strings.TrimPrefix(base, "http://")231 }232 return base233}234235// short renders a Unix timestamp as a compact UTC stamp for list rows.236func short(value any) string {237 seconds := unixSeconds(value)238 if seconds <= 0 {239 return ""240 }241 return time.Unix(seconds, 0).UTC().Format("Jan 2 15:04")242}243244func repoView(query url.Values) string {245 if view := query.Get("view"); view != "" {246 return view247 }248 return "tree"249}250251// scripts are served at /scripts/<name> and cached by the service worker.252// One version stamp covers them all, so a change to any file refreshes every253// cached copy together.254var scripts = map[string]string{"bridge.js": bridgeJS, "tiny.js": tinyJS, "components.js": componentsJS, "rooms.js": roomsJS, "chat.js": chatJS, "chat-presence.js": chatPresenceJS, "chat-activity.js": chatActivityJS, "grant-request.js": grantRequestJS, "chat-files.js": chatFilesJS, "direct-messages.js": directMessagesJS, "nostr-name.js": nostrNameJS, "blossom-encryption.js": blossomEncryptionJS, "blossom-manifests.js": blossomManifestsJS, "blossom-upload.js": blossomUploadJS, "file-messages.js": fileMessagesJS, "private-services.js": privateServicesJS, "file-workspace.js": fileWorkspaceJS, "file-catalog.js": fileCatalogJS}255256var scriptsVersion = func() string {257 names := make([]string, 0, len(scripts))258 for name := range scripts {259 names = append(names, name)260 }261 sort.Strings(names)262 digest := sha256.New()263 for _, name := range names {264 digest.Write([]byte(name))265 digest.Write([]byte{0})266 digest.Write([]byte(scripts[name]))267 }268 return hex.EncodeToString(digest.Sum(nil))[:12]269}()270271var (272 templateBaseOnce sync.Once273 templateBase *template.Template274 templateBaseErr error275)276277// parseTemplates clones the immutable parsed template set and installs the278// request backend's rendering helpers. Parsing is expensive, while the279// helpers must remain bound to the current App and cannot be shared safely.280func parseTemplates(a *App) (*template.Template, error) {281 templateBaseOnce.Do(func() { templateBase, templateBaseErr = parseTemplateBase() })282 if templateBaseErr != nil {283 return nil, templateBaseErr284 }285 tmpl, err := templateBase.Clone()286 if err != nil {287 return nil, fmt.Errorf("clone web templates: %w", err)288 }289 tmpl = tmpl.Funcs(template.FuncMap{290 "socialBody": a.socialBody,291 "markdown": a.markdown,292 "chatMarkdown": a.chatMarkdown,293 "wikiHTML": a.wikiHTML,294 })295 return tmpl, nil296}297298func parseTemplateBase() (*template.Template, error) {299 funcs := template.FuncMap{300 "stylesheet": func() template.CSS { return template.CSS(styleCSS) },301 "scriptURL": func(name string) (string, error) {302 if _, ok := scripts[name]; !ok {303 return "", fmt.Errorf("unknown script %q", name)304 }305 return "/scripts/" + name + "?v=" + scriptsVersion, nil306 },307 "scriptsVersion": func() string { return scriptsVersion },308 "json": func(value any) string {309 encoded, err := json.Marshal(value)310 if err != nil {311 return "null"312 }313 return string(encoded)314 },315 "join": strings.Join,316 "urlquery": url.QueryEscape,317 "asMap": valueMap,318 "str": plainString,319 "datetime": datetime,320 "when": when,321 "fileSize": fileSize,322 "socialThreadPage": socialThreadPage,323 "socialReactionCount": socialReactionCount,324 "socialReacted": socialReacted,325 "socialContext": socialContext,326 "socialQuery": socialQuery,327 "socialURL": socialURL,328 "socialThreadURL": socialURL,329 "socialBody": func(any) template.HTML { return "" },330 "socialPreview": socialPreview,331 "socialMediaURL": socialMediaURL,332 "avatarURL": avatarURL,333 "roomAvatarURL": roomAvatarURL,334 "mobileHeader": mobileHeader,335 "markdown": func(string) template.HTML { return "" },336 "chatMarkdown": func(string) template.HTML { return "" },337 "hasPrefix": strings.HasPrefix,338 "npub": identityNpub,339 "wsURL": wsURL,340 "short": short,341 "prompt": promptPath,342 "pageAddress": pageAddress,343 "signinURL": signinURL,344 "railKind": railKind,345 "repoView": repoView,346 "relayItems": func() []navItem { return relayNav },347 "manageItems": func() []navItem { return manageNav },348 "navGroups": navGroups,349 "add": func(a, b int) int { return a + b },350 "strs": func(values ...string) []string { return values },351 "repoCommitURL": repoCommitURL,352 "hasNextOffset": hasNextOffset,353 "repoURL": repoURL,354 "repoBreadcrumbs": repoBreadcrumbs,355 "browsePageURL": browsePageURL,356 "shortID": shortID,357 "repoDate": repoDate,358 "sourceHTML": sourceHTML,359 "diffHTML": diffHTML,360 "collabItems": collaborationItems,361 "collabReplies": collaborationReplies,362 "collabLabels": collaborationLabels,363 "collabProposal": collaborationProposal,364 "reviewDiff": reviewDiffView,365 "reviewAnchor": reviewAnchorQuery,366 "reviewLabel": reviewAnchorLabel,367 "agentState": agentState,368 "agentSince": agentSince,369 "agentScope": agentScope,370 "agentLabel": agentLabel,371 "agentCounts": agentCounts,372 "pendingRequests": pendingRequests,373 "decidedRequests": decidedRequests,374 "callbacksFor": callbacksFor,375 "callbackState": callbackState,376 "callbackKinds": callbackKinds,377 "callbackCounts": callbackCounts,378 "scopeList": scopeList,379 "dateAfter": dateAfter,380 "agentByKey": agentByKey,381 "repoLines": repoLines,382 "siteLines": siteLines,383 "dateOf": dateOf,384 "approvalViews": approvalViews,385 "approvalCounts": approvalCounts,386 "approvalDevices": approvalDevices,387 "wikiHTML": func(any) template.HTML { return "" },388 "viewState": customViewState,389 "viewKinds": customViewKinds,390 "viewLanguages": customViewLanguages,391 "wikiView": wikiPageView,392 "wikiProposal": wikiProposal,393 "wikiURL": wikiURL,394 "roomItems": roomItems,395 "roomRoot": roomRoot,396 "roomMembers": roomMembers,397 "roomContent": roomContent,398 "age": age,399 "clock": clock,400 "roomAdmin": roomAdmin,401402 "roomAttachmentContent": roomAttachmentContent,403 "roomAttachmentMarkup": roomAttachmentMarkup,404 }405 tmpl, err := template.New("webui").Funcs(funcs).ParseFS(templateFS, "*.html")406 if err != nil {407 return nil, fmt.Errorf("parse web templates: %w", err)408 }409 return tmpl, nil410}411412func valueMap(value any) map[string]any {413 if result, ok := value.(map[string]any); ok {414 return result415 }416 encoded, _ := json.Marshal(value)417 var result map[string]any418 _ = json.Unmarshal(encoded, &result)419 return result420}421422// plainString prints a decoded JSON value for display: nil becomes empty,423// whole floats print without a fraction, and everything else uses fmt.424func plainString(value any) string {425 switch value := value.(type) {426 case nil:427 return ""428 case string:429 return value430 case float64:431 if value == float64(int64(value)) {432 return fmt.Sprintf("%d", int64(value))433 }434 return fmt.Sprintf("%v", value)435 default:436 return fmt.Sprint(value)437 }438}439440// datetime renders a Unix timestamp as UTC text suitable for a time element.441func datetime(value any) string {442 seconds := unixSeconds(value)443 if seconds <= 0 {444 return ""445 }446 return time.Unix(seconds, 0).UTC().Format("2006-01-02T15:04:05Z")447}448449// when renders a Unix timestamp as short UTC text for people to read.450func when(value any) string {451 seconds := unixSeconds(value)452 if seconds <= 0 {453 return ""454 }455 return time.Unix(seconds, 0).UTC().Format("2006-01-02 15:04 UTC")456}457458func unixSeconds(value any) int64 {459 switch value := value.(type) {460 case float64:461 return int64(value)462 case int64:463 return value464 case int:465 return int64(value)466 }467 return 0468}469470// tableRows renders escaped table head and body markup for fragments and471// streams that swap into an existing table element.472func tableRows(headers []string, rows [][]string) string {473 var out strings.Builder474 out.WriteString(`<thead><tr>`)475 for _, header := range headers {476 out.WriteString(`<th scope="col">` + template.HTMLEscapeString(header) + `</th>`)477 }478 out.WriteString(`</tr></thead><tbody>`)479 if len(rows) == 0 {480 out.WriteString(`<tr><td colspan="` + fmt.Sprint(len(headers)) + `">No entries.</td></tr>`)481 }482 for _, row := range rows {483 out.WriteString(`<tr>`)484 for _, cell := range row {485 out.WriteString(`<td>` + template.HTMLEscapeString(cell) + `</td>`)486 }487 out.WriteString(`</tr>`)488 }489 out.WriteString(`</tbody>`)490 return out.String()491}