clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

main
tinyrelay / tinyclient / templates.go
templates.go 18.1K
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// friendlyError turns relay refusals into sentences; other errors pass through.150func friendlyError(err string) string {151 switch {152 case strings.HasPrefix(err, "restricted: this relay is members-only"):153 return "This relay is members-only. Ask the owner for an invite."154 case strings.HasPrefix(err, "auth-required: this room is members-only"):155 return "This room is members-only. Sign in to read it."156 case err == "authentication required":157 return "Sign in to see this."158 case strings.HasPrefix(err, "auth-required:"):159 rest := strings.TrimSpace(strings.TrimPrefix(err, "auth-required:"))160 if rest == "" || !strings.Contains(rest, " ") {161 return "Sign in to see this."162 }163 return strings.ToUpper(rest[:1]) + rest[1:] + "."164 case strings.HasPrefix(err, "not found: "):165 return "No such " + strings.TrimPrefix(err, "not found: ") + "."166 }167 return err168}169170// authRequired reports a refusal that a sign-in would resolve.171func authRequired(err string) bool {172 return err == "authentication required" || strings.HasPrefix(err, "auth-required:")173}174175// refName shows a symbolic head as its branch name and a commit as its short id.176func refName(value any) string {177 head := plainString(value)178 if strings.HasPrefix(head, "ref: refs/heads/") {179 return strings.TrimPrefix(head, "ref: refs/heads/")180 }181 if strings.HasPrefix(head, "ref: ") {182 return strings.TrimPrefix(head, "ref: ")183 }184 return shortID(head)185}186187// safeURL lets nostr: and web+nostr: links through the template sanitizer.188func safeURL(value string) any {189 if strings.HasPrefix(value, "nostr:") || strings.HasPrefix(value, "web+nostr:") {190 return template.URL(value)191 }192 return value193}194195// restricted reports a relay-level refusal so pages can hide forms that would fail.196func restricted(err string) bool { return strings.HasPrefix(err, "restricted:") }197198// navGroupLabels names the rail groups by kind; an empty name leaves a group unlabeled.199var navGroupLabels = map[string][]string{200 "relay": {"", "talk", "work", "relay"},201 "manage": {"people", "policy", "relay", "data", "status"},202}203204// navGroupLabel returns the label for group index i of a rail kind.205func navGroupLabel(kind string, i int) string {206 labels := navGroupLabels[kind]207 if i < 0 || i >= len(labels) {208 return ""209 }210 return labels[i]211}212213// navGroups splits a rail list into its groups, in order.214func navGroups(items []navItem) [][]navItem {215 var groups [][]navItem216 for _, item := range items {217 if len(groups) == 0 || groups[len(groups)-1][0].Group != item.Group {218 groups = append(groups, nil)219 }220 groups[len(groups)-1] = append(groups[len(groups)-1], item)221 }222 return groups223}224225// railKind picks the sitemap for a tab: one repository, the rooms,226// management, or the relay.227func railKind(tab string) string {228 if tab == "repo" {229 return "repo"230 }231 if tab == "social" || tab == "social-thread" || tab == "social-profile" {232 return "social"233 }234 if tab == "chat" || tab == "direct" || tab == "rooms" || tab == "room" || tab == "thread" {235 return "rooms"236 }237 for _, item := range manageNav {238 if item.Tab == tab {239 return "manage"240 }241 }242 return "relay"243}244245// promptPath renders the footer prompt: the path after the relay name, with246// repository pages spelled out as repos/<name>/<view>.247func promptPath(path string, query url.Values) string {248 path = strings.Trim(path, "/")249 switch {250 case path == "":251 return "home"252 case parseRepoRoute("/" + path).ok:253 return "repos/" + query.Get("repo") + "/" + repoView(query)254 case fileHash("/"+path) != "":255 return "files/" + shortID(fileHash("/"+path))256 }257 if room := roomRoute("/" + path); room.tab == "thread" {258 return "rooms/" + room.id + "/thread/" + shortID(room.event)259 }260 if strings.HasPrefix(path, "wiki/") {261 return "wiki/" + wikiPageName("/"+path)262 }263 return path264}265266// pageAddress is the address the crumb copies: the relay's public URL plus267// the page's canonical path, so a link copied on a tailnet or LAN address268// still opens for anyone. Wiki names are normalized the way the relay269// resolves them; the query keeps only what still narrows the page.270func pageAddress(base, path string, query url.Values) string {271 path = "/" + strings.Trim(path, "/")272 if path == "/" {273 path = ""274 }275 if strings.HasPrefix(path, "/wiki/") {276 path = "/wiki/" + url.PathEscape(wikiPageName(path))277 if id := wikiMergeID(path); id != "" {278 path += "/proposals/" + id279 }280 }281 keys := []string{"path", "ref", "q", "author", "version", "kind", "status", "label"}282 if parseRepoRoute(path).ok {283 keys = []string{"ref", "q", "status", "label"}284 }285 keep := url.Values{}286 for _, key := range keys {287 if value := query.Get(key); value != "" {288 keep.Set(key, value)289 }290 }291 address := strings.TrimSuffix(base, "/") + path292 if len(keep) > 0 {293 address += "?" + keep.Encode()294 }295 return address296}297298// wsURL turns the relay's public http(s) URL into its websocket form.299func wsURL(base string) string {300 switch {301 case strings.HasPrefix(base, "https://"):302 return "wss://" + strings.TrimPrefix(base, "https://")303 case strings.HasPrefix(base, "http://"):304 return "ws://" + strings.TrimPrefix(base, "http://")305 }306 return base307}308309// short renders a Unix timestamp as a compact UTC stamp for list rows.310func short(value any) string {311 seconds := unixSeconds(value)312 if seconds <= 0 {313 return ""314 }315 return time.Unix(seconds, 0).UTC().Format("Jan 2 15:04")316}317318func repoView(query url.Values) string {319 if view := query.Get("view"); view != "" {320 return view321 }322 return "tree"323}324325// scripts are served at /scripts/<name> and cached by the service worker.326// One version stamp covers them all, so a change to any file refreshes every327// cached copy together.328var 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}329330var scriptsVersion = func() string {331 names := make([]string, 0, len(scripts))332 for name := range scripts {333 names = append(names, name)334 }335 sort.Strings(names)336 digest := sha256.New()337 for _, name := range names {338 digest.Write([]byte(name))339 digest.Write([]byte{0})340 digest.Write([]byte(scripts[name]))341 }342 return hex.EncodeToString(digest.Sum(nil))[:12]343}()344345var (346 templateBaseOnce sync.Once347 templateBase *template.Template348 templateBaseErr error349)350351// parseTemplates clones the immutable parsed template set and installs the352// request backend's rendering helpers. Parsing is expensive, while the353// helpers must remain bound to the current App and cannot be shared safely.354func parseTemplates(a *App) (*template.Template, error) {355 templateBaseOnce.Do(func() { templateBase, templateBaseErr = parseTemplateBase() })356 if templateBaseErr != nil {357 return nil, templateBaseErr358 }359 tmpl, err := templateBase.Clone()360 if err != nil {361 return nil, fmt.Errorf("clone web templates: %w", err)362 }363 tmpl = tmpl.Funcs(template.FuncMap{364 "socialBody": a.socialBody,365 "podcastNotes": a.podcastNotes,366 "markdown": a.markdown,367 "chatMarkdown": a.chatMarkdown,368 "wikiHTML": a.wikiHTML,369 })370 return tmpl, nil371}372373func parseTemplateBase() (*template.Template, error) {374 funcs := template.FuncMap{375 "stylesheet": func() template.CSS { return template.CSS(styleCSS) },376 "scriptURL": func(name string) (string, error) {377 if _, ok := scripts[name]; !ok {378 return "", fmt.Errorf("unknown script %q", name)379 }380 return "/scripts/" + name + "?v=" + scriptsVersion, nil381 },382 "scriptsVersion": func() string { return scriptsVersion },383 "json": func(value any) string {384 encoded, err := json.Marshal(value)385 if err != nil {386 return "null"387 }388 return string(encoded)389 },390 "join": strings.Join,391 "urlquery": url.QueryEscape,392 "asMap": valueMap,393 "str": plainString,394 "datetime": datetime,395 "when": when,396 "fileSize": fileSize,397 "socialThreadPage": socialThreadPage,398 "socialReactionCount": socialReactionCount,399 "socialReacted": socialReacted,400 "socialContext": socialContext,401 "socialViews": socialViews,402 "socialView": socialView,403 "socialFeedPage": socialFeedPage,404 "podcastFeedURL": podcastFeedURL,405 "podcastEpisode": podcastEpisode,406 "podcastNotes": func(any) template.HTML { return "" },407 "podcastProfile": podcastProfile,408 "socialQuery": socialQuery,409 "socialURL": socialURL,410 "socialThreadURL": socialURL,411 "socialBody": func(any) template.HTML { return "" },412 "socialPreview": socialPreview,413 "socialMediaURL": socialMediaURL,414 "avatarURL": avatarURL,415 "roomAvatarURL": roomAvatarURL,416 "mobileHeader": mobileHeader,417 "markdown": func(string) template.HTML { return "" },418 "chatMarkdown": func(string) template.HTML { return "" },419 "hasPrefix": strings.HasPrefix,420 "npub": identityNpub,421 "wsURL": wsURL,422 "short": short,423 "prompt": promptPath,424 "pageAddress": pageAddress,425 "signinURL": signinURL,426 "railKind": railKind,427 "repoView": repoView,428 "navGroupLabel": navGroupLabel,429 "friendlyError": friendlyError,430 "restricted": restricted,431 "authRequired": authRequired,432 "refName": refName,433 "safeURL": safeURL,434 "relayItems": func() []navItem { return relayNav },435 "manageItems": func() []navItem { return manageNav },436 "navGroups": navGroups,437 "add": func(a, b int) int { return a + b },438 "strs": func(values ...string) []string { return values },439 "repoCommitURL": repoCommitURL,440 "hasNextOffset": hasNextOffset,441 "repoURL": repoURL,442 "repoPagePath": repoPagePath,443 "repoPageURL": repoPageURL,444 "repoHref": repoHref,445 "repoBreadcrumbs": repoBreadcrumbs,446 "browsePageURL": browsePageURL,447 "shortID": shortID,448 "repoDate": repoDate,449 "sourceHTML": sourceHTML,450 "diffHTML": diffHTML,451 "collabItems": collaborationItems,452 "collabReplies": collaborationReplies,453 "collabLabels": collaborationLabels,454 "collabProposal": collaborationProposal,455 "reviewDiff": reviewDiffView,456 "reviewAnchor": reviewAnchorQuery,457 "reviewLabel": reviewAnchorLabel,458 "agentState": agentState,459 "agentSince": agentSince,460 "agentScope": agentScope,461 "agentLabel": agentLabel,462 "agentCounts": agentCounts,463 "pendingRequests": pendingRequests,464 "decidedRequests": decidedRequests,465 "callbacksFor": callbacksFor,466 "callbackState": callbackState,467 "callbackKinds": callbackKinds,468 "callbackCounts": callbackCounts,469 "scopeList": scopeList,470 "dateAfter": dateAfter,471 "agentByKey": agentByKey,472 "repoLines": repoLines,473 "siteLines": siteLines,474 "dateOf": dateOf,475 "approvalViews": approvalViews,476 "approvalCounts": approvalCounts,477 "approvalDevices": approvalDevices,478 "wikiHTML": func(any) template.HTML { return "" },479 "viewState": customViewState,480 "viewKinds": customViewKinds,481 "viewLanguages": customViewLanguages,482 "wikiView": wikiPageView,483 "wikiProposal": wikiProposal,484 "wikiURL": wikiURL,485 "roomItems": roomItems,486 "roomRoot": roomRoot,487 "roomMembers": roomMembers,488 "roomContent": roomContent,489 "age": age,490 "clock": clock,491 "roomAdmin": roomAdmin,492493 "roomAttachmentContent": roomAttachmentContent,494 "roomAttachmentMarkup": roomAttachmentMarkup,495 }496 tmpl, err := template.New("webui").Funcs(funcs).ParseFS(templateFS, "*.html")497 if err != nil {498 return nil, fmt.Errorf("parse web templates: %w", err)499 }500 return tmpl, nil501}502503func valueMap(value any) map[string]any {504 if result, ok := value.(map[string]any); ok {505 return result506 }507 encoded, _ := json.Marshal(value)508 var result map[string]any509 _ = json.Unmarshal(encoded, &result)510 return result511}512513// plainString prints a decoded JSON value for display: nil becomes empty,514// whole floats print without a fraction, and everything else uses fmt.515func plainString(value any) string {516 switch value := value.(type) {517 case nil:518 return ""519 case string:520 return value521 case float64:522 if value == float64(int64(value)) {523 return fmt.Sprintf("%d", int64(value))524 }525 return fmt.Sprintf("%v", value)526 default:527 return fmt.Sprint(value)528 }529}530531// datetime renders a Unix timestamp as UTC text suitable for a time element.532func datetime(value any) string {533 seconds := unixSeconds(value)534 if seconds <= 0 {535 return ""536 }537 return time.Unix(seconds, 0).UTC().Format("2006-01-02T15:04:05Z")538}539540// when renders a Unix timestamp as short UTC text for people to read.541func when(value any) string {542 seconds := unixSeconds(value)543 if seconds <= 0 {544 return ""545 }546 return time.Unix(seconds, 0).UTC().Format("2006-01-02 15:04 UTC")547}548549func unixSeconds(value any) int64 {550 switch value := value.(type) {551 case float64:552 return int64(value)553 case int64:554 return value555 case int:556 return int64(value)557 }558 return 0559}560561// tableRows renders escaped table head and body markup for fragments and562// streams that swap into an existing table element.563func tableRows(headers []string, rows [][]string) string {564 var out strings.Builder565 out.WriteString(`<thead><tr>`)566 for _, header := range headers {567 out.WriteString(`<th scope="col">` + template.HTMLEscapeString(header) + `</th>`)568 }569 out.WriteString(`</tr></thead><tbody>`)570 if len(rows) == 0 {571 out.WriteString(`<tr><td colspan="` + fmt.Sprint(len(headers)) + `">No entries.</td></tr>`)572 }573 for _, row := range rows {574 out.WriteString(`<tr>`)575 for _, cell := range row {576 out.WriteString(`<td>` + template.HTMLEscapeString(cell) + `</td>`)577 }578 out.WriteString(`</tr>`)579 }580 out.WriteString(`</tbody>`)581 return out.String()582}