| 1 | package tinyclient |
| 2 | |
| 3 | import ( |
| 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 | ) |
| 16 | |
| 17 | // templateFS holds every page template. page.html owns the shell and the |
| 18 | // 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.html |
| 21 | var templateFS embed.FS |
| 22 | |
| 23 | // styleCSS is inlined into every page so the UI needs no extra request and |
| 24 | // works unchanged under a tenant path prefix. It styles ids, elements and the |
| 25 | // custom element names only; templates never carry class attributes. |
| 26 | // |
| 27 | //go:embed style.css |
| 28 | var styleCSS string |
| 29 | |
| 30 | // bridgeJS is the signer bridge and componentsJS defines the custom |
| 31 | // elements. Both are inlined at the end of the body in that order. |
| 32 | // |
| 33 | //go:embed bridge.js |
| 34 | var bridgeJS string |
| 35 | |
| 36 | //go:embed tiny.js |
| 37 | var tinyJS string |
| 38 | |
| 39 | //go:embed components.js |
| 40 | var componentsJS string |
| 41 | |
| 42 | //go:embed rooms.js |
| 43 | var roomsJS string |
| 44 | |
| 45 | //go:embed chat-presence.js |
| 46 | var chatPresenceJS string |
| 47 | |
| 48 | //go:embed chat-activity.js |
| 49 | var chatActivityJS string |
| 50 | |
| 51 | //go:embed grant-request.js |
| 52 | var grantRequestJS string |
| 53 | |
| 54 | //go:embed chat.js |
| 55 | var chatJS string |
| 56 | |
| 57 | //go:embed chat-files.js |
| 58 | var chatFilesJS string |
| 59 | |
| 60 | //go:embed direct-messages.js |
| 61 | var directMessagesJS string |
| 62 | |
| 63 | // nostr-name.js is the vendored <nostr-name> element with the relay-local |
| 64 | // profile loader; rebuild it with npm run build:nostr-name. |
| 65 | // |
| 66 | //go:embed nostr-name.js |
| 67 | var nostrNameJS string |
| 68 | |
| 69 | //go:embed blossom-encryption.js |
| 70 | var blossomEncryptionJS string |
| 71 | |
| 72 | //go:embed blossom-manifests.js |
| 73 | var blossomManifestsJS string |
| 74 | |
| 75 | //go:embed file-messages.js |
| 76 | var fileMessagesJS string |
| 77 | |
| 78 | //go:embed private-services.js |
| 79 | var privateServicesJS string |
| 80 | |
| 81 | //go:embed blossom-upload.js |
| 82 | var blossomUploadJS string |
| 83 | |
| 84 | //go:embed file-workspace.js |
| 85 | var fileWorkspaceJS string |
| 86 | |
| 87 | //go:embed file-catalog.js |
| 88 | var fileCatalogJS string |
| 89 | |
| 90 | // Installable app assets. The manifest is a template: NAME and BASE are |
| 91 | // replaced per request so tenant prefixes and relay names stay correct. |
| 92 | // |
| 93 | //go:embed manifest.webmanifest |
| 94 | var manifestTemplate string |
| 95 | |
| 96 | //go:embed icon-192.png |
| 97 | var icon192PNG []byte |
| 98 | |
| 99 | //go:embed icon-512.png |
| 100 | var icon512PNG []byte |
| 101 | |
| 102 | //go:embed icon-maskable-512.png |
| 103 | var iconMaskablePNG []byte |
| 104 | |
| 105 | //go:embed apple-touch-icon.png |
| 106 | var appleTouchIconPNG []byte |
| 107 | |
| 108 | //go:embed badge-96.png |
| 109 | var badgePNG []byte |
| 110 | |
| 111 | //go:embed screenshot-narrow.png |
| 112 | var screenshotNarrowPNG []byte |
| 113 | |
| 114 | //go:embed screenshot-wide.png |
| 115 | var screenshotWidePNG []byte |
| 116 | |
| 117 | //go:embed sw.js |
| 118 | var serviceWorkerJS []byte |
| 119 | |
| 120 | //go:embed icon.svg |
| 121 | var iconSVG []byte |
| 122 | |
| 123 | //go:embed icon-mono.svg |
| 124 | var iconMonoSVG []byte |
| 125 | |
| 126 | // navItem is one rail entry. Tab matches PageData.Tab for the active state; |
| 127 | // Group separates related destinations with a small gap. |
| 128 | type navItem struct { |
| 129 | Label, Href, Tab string |
| 130 | Group int |
| 131 | } |
| 132 | |
| 133 | // relayNav groups discovery, conversations, shared work and administration. |
| 134 | var 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 | } |
| 146 | |
| 147 | var 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}} |
| 148 | |
| 149 | // navGroups splits a rail list into its groups, in order. |
| 150 | func navGroups(items []navItem) [][]navItem { |
| 151 | var groups [][]navItem |
| 152 | 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 groups |
| 159 | } |
| 160 | |
| 161 | // railKind picks the sitemap for a tab: one repository, the rooms, |
| 162 | // management, or the relay. |
| 163 | func 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 | } |
| 177 | |
| 178 | // promptPath renders the footer prompt: the path after the relay name, with |
| 179 | // repository pages spelled out as repos/<name>/<view>. |
| 180 | func 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 path |
| 197 | } |
| 198 | |
| 199 | // pageAddress is the address the crumb copies: the relay's public URL plus |
| 200 | // the page's canonical path, so a link copied on a tailnet or LAN address |
| 201 | // still opens for anyone. Wiki names are normalized the way the relay |
| 202 | // resolves them; repositories and files keep the query that names them. |
| 203 | func 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, "/" ) + path |
| 218 | if len(keep) > 0 { |
| 219 | address += "?" + keep.Encode() |
| 220 | } |
| 221 | return address |
| 222 | } |
| 223 | |
| 224 | // wsURL turns the relay's public http(s) URL into its websocket form. |
| 225 | func 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 base |
| 233 | } |
| 234 | |
| 235 | // short renders a Unix timestamp as a compact UTC stamp for list rows. |
| 236 | func 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 | } |
| 243 | |
| 244 | func repoView(query url.Values) string { |
| 245 | if view := query.Get("view" ); view != "" { |
| 246 | return view |
| 247 | } |
| 248 | return "tree" |
| 249 | } |
| 250 | |
| 251 | // 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 every |
| 253 | // cached copy together. |
| 254 | var 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} |
| 255 | |
| 256 | var 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 | }() |
| 270 | |
| 271 | var ( |
| 272 | templateBaseOnce sync.Once |
| 273 | templateBase *template.Template |
| 274 | templateBaseErr error |
| 275 | ) |
| 276 | |
| 277 | // parseTemplates clones the immutable parsed template set and installs the |
| 278 | // request backend's rendering helpers. Parsing is expensive, while the |
| 279 | // helpers must remain bound to the current App and cannot be shared safely. |
| 280 | func parseTemplates(a *App) (*template.Template, error) { |
| 281 | templateBaseOnce.Do(func() { templateBase, templateBaseErr = parseTemplateBase() }) |
| 282 | if templateBaseErr != nil { |
| 283 | return nil, templateBaseErr |
| 284 | } |
| 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, nil |
| 296 | } |
| 297 | |
| 298 | func 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, nil |
| 306 | }, |
| 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, |
| 401 | |
| 402 | "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, nil |
| 410 | } |
| 411 | |
| 412 | func valueMap(value any) map[string]any { |
| 413 | if result, ok := value.(map[string]any); ok { |
| 414 | return result |
| 415 | } |
| 416 | encoded, _ := json.Marshal(value) |
| 417 | var result map[string]any |
| 418 | _ = json.Unmarshal(encoded, &result) |
| 419 | return result |
| 420 | } |
| 421 | |
| 422 | // plainString prints a decoded JSON value for display: nil becomes empty, |
| 423 | // whole floats print without a fraction, and everything else uses fmt. |
| 424 | func plainString(value any) string { |
| 425 | switch value := value.(type) { |
| 426 | case nil: |
| 427 | return "" |
| 428 | case string: |
| 429 | return value |
| 430 | 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 | } |
| 439 | |
| 440 | // datetime renders a Unix timestamp as UTC text suitable for a time element. |
| 441 | func 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 | } |
| 448 | |
| 449 | // when renders a Unix timestamp as short UTC text for people to read. |
| 450 | func 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 | } |
| 457 | |
| 458 | func unixSeconds(value any) int64 { |
| 459 | switch value := value.(type) { |
| 460 | case float64: |
| 461 | return int64(value) |
| 462 | case int64: |
| 463 | return value |
| 464 | case int: |
| 465 | return int64(value) |
| 466 | } |
| 467 | return 0 |
| 468 | } |
| 469 | |
| 470 | // tableRows renders escaped table head and body markup for fragments and |
| 471 | // streams that swap into an existing table element. |
| 472 | func tableRows(headers []string, rows [][]string) string { |
| 473 | var out strings.Builder |
| 474 | 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 | } |