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 social_profile.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 == "social" || tab == "social-thread" || tab == "social-profile" {168 return "social"169 }170 if tab == "chat" || tab == "direct" || tab == "rooms" || tab == "room" || tab == "thread" {171 return "rooms"172 }173 for _, item := range manageNav {174 if item.Tab == tab {175 return "manage"176 }177 }178 return "relay"179}180181// promptPath renders the footer prompt: the path after the relay name, with182// repository pages spelled out as repos/<name>/<view>.183func promptPath(path string, query url.Values) string {184 path = strings.Trim(path, "/")185 switch {186 case path == "":187 return "home"188 case path == "repo" && query.Get("repo") != "":189 return "repos/" + query.Get("repo") + "/" + repoView(query)190 case path == "file" && (query.Get("sha") != "" || query.Get("hash") != ""):191 return "files/" + shortID(query.Get("sha")+query.Get("hash"))192 }193 if room := roomRoute("/" + path); room.tab == "thread" {194 return "rooms/" + room.id + "/thread/" + shortID(room.event)195 }196 if strings.HasPrefix(path, "wiki/") {197 return "wiki/" + wikiPageName("/"+path)198 }199 return path200}201202// pageAddress is the address the crumb copies: the relay's public URL plus203// the page's canonical path, so a link copied on a tailnet or LAN address204// still opens for anyone. Wiki names are normalized the way the relay205// resolves them; repositories and files keep the query that names them.206func pageAddress(base, path string, query url.Values) string {207 path = "/" + strings.Trim(path, "/")208 if path == "/" {209 path = ""210 }211 if strings.HasPrefix(path, "/wiki/") {212 path = "/wiki/" + url.PathEscape(wikiPageName(path))213 }214 keep := url.Values{}215 for _, key := range []string{"owner", "repo", "view", "path", "ref", "sha", "hash", "q", "id", "author", "version", "merge", "address", "kind"} {216 if value := query.Get(key); value != "" {217 keep.Set(key, value)218 }219 }220 address := strings.TrimSuffix(base, "/") + path221 if len(keep) > 0 {222 address += "?" + keep.Encode()223 }224 return address225}226227// wsURL turns the relay's public http(s) URL into its websocket form.228func wsURL(base string) string {229 switch {230 case strings.HasPrefix(base, "https://"):231 return "wss://" + strings.TrimPrefix(base, "https://")232 case strings.HasPrefix(base, "http://"):233 return "ws://" + strings.TrimPrefix(base, "http://")234 }235 return base236}237238// short renders a Unix timestamp as a compact UTC stamp for list rows.239func short(value any) string {240 seconds := unixSeconds(value)241 if seconds <= 0 {242 return ""243 }244 return time.Unix(seconds, 0).UTC().Format("Jan 2 15:04")245}246247func repoView(query url.Values) string {248 if view := query.Get("view"); view != "" {249 return view250 }251 return "tree"252}253254// scripts are served at /scripts/<name> and cached by the service worker.255// One version stamp covers them all, so a change to any file refreshes every256// cached copy together.257var 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}258259var scriptsVersion = func() string {260 names := make([]string, 0, len(scripts))261 for name := range scripts {262 names = append(names, name)263 }264 sort.Strings(names)265 digest := sha256.New()266 for _, name := range names {267 digest.Write([]byte(name))268 digest.Write([]byte{0})269 digest.Write([]byte(scripts[name]))270 }271 return hex.EncodeToString(digest.Sum(nil))[:12]272}()273274var (275 templateBaseOnce sync.Once276 templateBase *template.Template277 templateBaseErr error278)279280// parseTemplates clones the immutable parsed template set and installs the281// request backend's rendering helpers. Parsing is expensive, while the282// helpers must remain bound to the current App and cannot be shared safely.283func parseTemplates(a *App) (*template.Template, error) {284 templateBaseOnce.Do(func() { templateBase, templateBaseErr = parseTemplateBase() })285 if templateBaseErr != nil {286 return nil, templateBaseErr287 }288 tmpl, err := templateBase.Clone()289 if err != nil {290 return nil, fmt.Errorf("clone web templates: %w", err)291 }292 tmpl = tmpl.Funcs(template.FuncMap{293 "socialBody": a.socialBody,294 "podcastNotes": a.podcastNotes,295 "markdown": a.markdown,296 "chatMarkdown": a.chatMarkdown,297 "wikiHTML": a.wikiHTML,298 })299 return tmpl, nil300}301302func parseTemplateBase() (*template.Template, error) {303 funcs := template.FuncMap{304 "stylesheet": func() template.CSS { return template.CSS(styleCSS) },305 "scriptURL": func(name string) (string, error) {306 if _, ok := scripts[name]; !ok {307 return "", fmt.Errorf("unknown script %q", name)308 }309 return "/scripts/" + name + "?v=" + scriptsVersion, nil310 },311 "scriptsVersion": func() string { return scriptsVersion },312 "json": func(value any) string {313 encoded, err := json.Marshal(value)314 if err != nil {315 return "null"316 }317 return string(encoded)318 },319 "join": strings.Join,320 "urlquery": url.QueryEscape,321 "asMap": valueMap,322 "str": plainString,323 "datetime": datetime,324 "when": when,325 "fileSize": fileSize,326 "socialThreadPage": socialThreadPage,327 "socialReactionCount": socialReactionCount,328 "socialReacted": socialReacted,329 "socialContext": socialContext,330 "socialViews": socialViews,331 "socialView": socialView,332 "socialFeedPage": socialFeedPage,333 "podcastFeedURL": podcastFeedURL,334 "podcastEpisode": podcastEpisode,335 "podcastNotes": func(any) template.HTML { return "" },336 "podcastProfile": podcastProfile,337 "socialQuery": socialQuery,338 "socialURL": socialURL,339 "socialThreadURL": socialURL,340 "socialBody": func(any) template.HTML { return "" },341 "socialPreview": socialPreview,342 "socialMediaURL": socialMediaURL,343 "avatarURL": avatarURL,344 "roomAvatarURL": roomAvatarURL,345 "mobileHeader": mobileHeader,346 "markdown": func(string) template.HTML { return "" },347 "chatMarkdown": func(string) template.HTML { return "" },348 "hasPrefix": strings.HasPrefix,349 "npub": identityNpub,350 "wsURL": wsURL,351 "short": short,352 "prompt": promptPath,353 "pageAddress": pageAddress,354 "signinURL": signinURL,355 "railKind": railKind,356 "repoView": repoView,357 "relayItems": func() []navItem { return relayNav },358 "manageItems": func() []navItem { return manageNav },359 "navGroups": navGroups,360 "add": func(a, b int) int { return a + b },361 "strs": func(values ...string) []string { return values },362 "repoCommitURL": repoCommitURL,363 "hasNextOffset": hasNextOffset,364 "repoURL": repoURL,365 "repoBreadcrumbs": repoBreadcrumbs,366 "browsePageURL": browsePageURL,367 "shortID": shortID,368 "repoDate": repoDate,369 "sourceHTML": sourceHTML,370 "diffHTML": diffHTML,371 "collabItems": collaborationItems,372 "collabReplies": collaborationReplies,373 "collabLabels": collaborationLabels,374 "collabProposal": collaborationProposal,375 "reviewDiff": reviewDiffView,376 "reviewAnchor": reviewAnchorQuery,377 "reviewLabel": reviewAnchorLabel,378 "agentState": agentState,379 "agentSince": agentSince,380 "agentScope": agentScope,381 "agentLabel": agentLabel,382 "agentCounts": agentCounts,383 "pendingRequests": pendingRequests,384 "decidedRequests": decidedRequests,385 "callbacksFor": callbacksFor,386 "callbackState": callbackState,387 "callbackKinds": callbackKinds,388 "callbackCounts": callbackCounts,389 "scopeList": scopeList,390 "dateAfter": dateAfter,391 "agentByKey": agentByKey,392 "repoLines": repoLines,393 "siteLines": siteLines,394 "dateOf": dateOf,395 "approvalViews": approvalViews,396 "approvalCounts": approvalCounts,397 "approvalDevices": approvalDevices,398 "wikiHTML": func(any) template.HTML { return "" },399 "viewState": customViewState,400 "viewKinds": customViewKinds,401 "viewLanguages": customViewLanguages,402 "wikiView": wikiPageView,403 "wikiProposal": wikiProposal,404 "wikiURL": wikiURL,405 "roomItems": roomItems,406 "roomRoot": roomRoot,407 "roomMembers": roomMembers,408 "roomContent": roomContent,409 "age": age,410 "clock": clock,411 "roomAdmin": roomAdmin,412413 "roomAttachmentContent": roomAttachmentContent,414 "roomAttachmentMarkup": roomAttachmentMarkup,415 }416 tmpl, err := template.New("webui").Funcs(funcs).ParseFS(templateFS, "*.html")417 if err != nil {418 return nil, fmt.Errorf("parse web templates: %w", err)419 }420 return tmpl, nil421}422423func valueMap(value any) map[string]any {424 if result, ok := value.(map[string]any); ok {425 return result426 }427 encoded, _ := json.Marshal(value)428 var result map[string]any429 _ = json.Unmarshal(encoded, &result)430 return result431}432433// plainString prints a decoded JSON value for display: nil becomes empty,434// whole floats print without a fraction, and everything else uses fmt.435func plainString(value any) string {436 switch value := value.(type) {437 case nil:438 return ""439 case string:440 return value441 case float64:442 if value == float64(int64(value)) {443 return fmt.Sprintf("%d", int64(value))444 }445 return fmt.Sprintf("%v", value)446 default:447 return fmt.Sprint(value)448 }449}450451// datetime renders a Unix timestamp as UTC text suitable for a time element.452func datetime(value any) string {453 seconds := unixSeconds(value)454 if seconds <= 0 {455 return ""456 }457 return time.Unix(seconds, 0).UTC().Format("2006-01-02T15:04:05Z")458}459460// when renders a Unix timestamp as short UTC text for people to read.461func when(value any) string {462 seconds := unixSeconds(value)463 if seconds <= 0 {464 return ""465 }466 return time.Unix(seconds, 0).UTC().Format("2006-01-02 15:04 UTC")467}468469func unixSeconds(value any) int64 {470 switch value := value.(type) {471 case float64:472 return int64(value)473 case int64:474 return value475 case int:476 return int64(value)477 }478 return 0479}480481// tableRows renders escaped table head and body markup for fragments and482// streams that swap into an existing table element.483func tableRows(headers []string, rows [][]string) string {484 var out strings.Builder485 out.WriteString(`<thead><tr>`)486 for _, header := range headers {487 out.WriteString(`<th scope="col">` + template.HTMLEscapeString(header) + `</th>`)488 }489 out.WriteString(`</tr></thead><tbody>`)490 if len(rows) == 0 {491 out.WriteString(`<tr><td colspan="` + fmt.Sprint(len(headers)) + `">No entries.</td></tr>`)492 }493 for _, row := range rows {494 out.WriteString(`<tr>`)495 for _, cell := range row {496 out.WriteString(`<td>` + template.HTMLEscapeString(cell) + `</td>`)497 }498 out.WriteString(`</tr>`)499 }500 out.WriteString(`</tbody>`)501 return out.String()502}