| 1 | package tinyclient |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | _ "embed" |
| 7 | "encoding/json" |
| 8 | "fmt" |
| 9 | "html/template" |
| 10 | "net/http" |
| 11 | "net/url" |
| 12 | "strconv" |
| 13 | "strings" |
| 14 | "time" |
| 15 | |
| 16 | "github.com/FelineStateMachine/tinyrelay/internal/policy" |
| 17 | "github.com/FelineStateMachine/tinyrelay/internal/views" |
| 18 | "github.com/skip2/go-qrcode" |
| 19 | ) |
| 20 | |
| 21 | // signerJS is the vendored MIT licensed Nostr signer bundle used for NIP-46 |
| 22 | // and Nostr Connect browser flows. It contains no relay branding or secrets. |
| 23 | // |
| 24 | //go:embed signer.js |
| 25 | var signerJS []byte |
| 26 | |
| 27 | // fixiJS is the vendored BSD-0 all-in-one bundle. It provides optional |
| 28 | // hypermedia fragments, focus-preserving swaps, small form state helpers, SSE |
| 29 | // handling, and fetch wrappers for pages that opt in with fx-* attributes. |
| 30 | // |
| 31 | //go:embed fixi.js |
| 32 | var fixiJS []byte |
| 33 | |
| 34 | // Backend supplies relay identity and the method calls used by page handlers. |
| 35 | // Query receives the request context, method name, JSON arguments and actor |
| 36 | // public key, and returns the method's JSON-shaped result. Policy, URL, slug |
| 37 | // and identity describe the relay rendered into each page. |
| 38 | type Backend interface { |
| 39 | Query(ctx context.Context, method string, params []json.RawMessage, actor string) (any, error) |
| 40 | Policy() Policy |
| 41 | URL() string |
| 42 | Slug() string |
| 43 | Identity() string |
| 44 | } |
| 45 | |
| 46 | // AccountPreferencesReader provides private, account-scoped preferences to |
| 47 | // server-rendered account pages. It is optional for small embedders. |
| 48 | type AccountPreferencesReader interface { |
| 49 | SharePresence(context.Context, string) bool |
| 50 | } |
| 51 | |
| 52 | // CustomViewSource is the optional backend summary of the custom views the |
| 53 | // renderers show in place of fenced code blocks: names and languages only. |
| 54 | type CustomViewSource interface { |
| 55 | CustomViews() []CustomView |
| 56 | } |
| 57 | |
| 58 | // ActorResolver returns the authenticated public key associated with request. |
| 59 | // An empty key represents an anonymous browser request. |
| 60 | type ActorResolver func(request *http.Request) (string, error) |
| 61 | |
| 62 | // QRProvider encodes text as an image for pages that show a connection QR. |
| 63 | type QRProvider func(text string) ([]byte, error) |
| 64 | |
| 65 | type App struct { |
| 66 | backend Backend |
| 67 | actor ActorResolver |
| 68 | qr QRProvider |
| 69 | tmpl *template.Template |
| 70 | version string |
| 71 | revision string |
| 72 | } |
| 73 | |
| 74 | type Options struct { |
| 75 | Actor ActorResolver |
| 76 | QR QRProvider |
| 77 | // Version and Revision identify the relay build on the health page. |
| 78 | Version, Revision string |
| 79 | } |
| 80 | |
| 81 | type PageData struct { |
| 82 | Activity chatActivityPage |
| 83 | SharePresence bool |
| 84 | Version string |
| 85 | Revision string |
| 86 | Title string |
| 87 | URL string |
| 88 | Slug string |
| 89 | Identity string |
| 90 | Policy policy.Policy |
| 91 | Actor string |
| 92 | Owner bool |
| 93 | Tab string |
| 94 | Notice string |
| 95 | Error string |
| 96 | Methods []string |
| 97 | Event any |
| 98 | Invite string |
| 99 | View string |
| 100 | Feed []any |
| 101 | Base string |
| 102 | Query url.Values |
| 103 | Private bool |
| 104 | Path string |
| 105 | Readme template.HTML |
| 106 | Tree []any |
| 107 | // Merge is the browsewikimerge detail shown by a wiki page compare view. |
| 108 | Merge any |
| 109 | // Connections lists the ways to open this relay in client apps that the |
| 110 | // viewer may see, with link placeholders already resolved. |
| 111 | Connections []any |
| 112 | // Rooms lists the rooms the viewer may see; the rooms rail renders it. |
| 113 | Rooms []any |
| 114 | // Callbacks lists the event callbacks shown beside each agent. |
| 115 | Callbacks []any |
| 116 | // CustomViews lists the owner's custom views on the Views page. |
| 117 | CustomViews []any |
| 118 | // Requests lists the access requests on the People page, pending first; |
| 119 | // Review is true when the viewer may decide them. |
| 120 | Requests []any |
| 121 | Review bool |
| 122 | } |
| 123 | |
| 124 | func New(backend Backend, options Options) (*App, error) { |
| 125 | if backend == nil { |
| 126 | return nil, fmt.Errorf("tinyclient backend is required" ) |
| 127 | } |
| 128 | qr := options.QR |
| 129 | if qr == nil { |
| 130 | qr = defaultQR |
| 131 | } |
| 132 | a := &App{backend: backend, actor: options.Actor, qr: qr, version: options.Version, revision: options.Revision} |
| 133 | tmpl, err := parseTemplates(a) |
| 134 | if err != nil { |
| 135 | return nil, err |
| 136 | } |
| 137 | a.tmpl = tmpl |
| 138 | return a, nil |
| 139 | } |
| 140 | |
| 141 | // blocks is the block renderer for the custom views the backend reports, |
| 142 | // or nil when there are none so fenced blocks stay code. |
| 143 | func (a *App) blocks() blockRenderer { |
| 144 | source, ok := a.backend.(CustomViewSource) |
| 145 | if !ok { |
| 146 | return nil |
| 147 | } |
| 148 | renderer := views.NewRenderer(source.CustomViews()) |
| 149 | if len(renderer.Languages) == 0 { |
| 150 | return nil |
| 151 | } |
| 152 | return renderer.Block |
| 153 | } |
| 154 | |
| 155 | func (a *App) markdown(source string) template.HTML { |
| 156 | return renderMarkdownWith(source, a.blocks()) |
| 157 | } |
| 158 | |
| 159 | func (a *App) chatMarkdown(source string) template.HTML { |
| 160 | return renderChatMarkdownWith(source, a.blocks()) |
| 161 | } |
| 162 | |
| 163 | func defaultQR(text string) ([]byte, error) { |
| 164 | code, err := qrcode.New(text, qrcode.Medium) |
| 165 | if err != nil { |
| 166 | return nil, fmt.Errorf("encode QR: %w" , err) |
| 167 | } |
| 168 | bitmap := code.Bitmap() |
| 169 | var out strings.Builder |
| 170 | size := len(bitmap) |
| 171 | fmt.Fprintf(&out, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" shape-rendering="crispEdges"><rect width="100%%" height="100%%" fill="white"/>` , size, size) |
| 172 | for y, row := range bitmap { |
| 173 | for x, dark := range row { |
| 174 | if dark { |
| 175 | fmt.Fprintf(&out, `<rect x="%d" y="%d" width="1" height="1"/>` , x, y) |
| 176 | } |
| 177 | } |
| 178 | } |
| 179 | out.WriteString(`</svg>` ) |
| 180 | return []byte(out.String()), nil |
| 181 | } |
| 182 | |
| 183 | func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) { |
| 184 | if request.URL.Path == BackendPath { |
| 185 | a.BackendHandler().ServeHTTP(writer, request) |
| 186 | return |
| 187 | } |
| 188 | if strings.HasPrefix(request.URL.Path, "/avatars/" ) { |
| 189 | serveAvatar(writer, request) |
| 190 | return |
| 191 | } |
| 192 | if a.denyPrivateEndpoint(writer, request) { |
| 193 | return |
| 194 | } |
| 195 | if a.handleChatRoute(writer, request) || a.handleControlRoute(writer, request) || a.handleWebMCPRoute(writer, request) || a.handleBrowseRoute(writer, request) { |
| 196 | return |
| 197 | } |
| 198 | if a.handleSocialRoute(writer, request) { |
| 199 | return |
| 200 | } |
| 201 | if request.Method != http.MethodGet && request.Method != http.MethodHead { |
| 202 | http.Error(writer, "method not allowed" , http.StatusMethodNotAllowed) |
| 203 | return |
| 204 | } |
| 205 | if request.URL.Path == "/card.json" { |
| 206 | a.card(writer) |
| 207 | return |
| 208 | } |
| 209 | if request.URL.Path == "/people" { |
| 210 | a.peopleJSON(writer, request) |
| 211 | return |
| 212 | } |
| 213 | if request.URL.Path == "/connect.json" { |
| 214 | a.connectJSON(writer, request) |
| 215 | return |
| 216 | } |
| 217 | if strings.HasPrefix(request.URL.Path, "/e/" ) || strings.HasPrefix(request.URL.Path, "/a/" ) { |
| 218 | if !a.pageFeatureEnabled(request.URL.Path) { |
| 219 | http.NotFound(writer, request) |
| 220 | return |
| 221 | } |
| 222 | a.eventPage(writer, request) |
| 223 | return |
| 224 | } |
| 225 | if strings.HasPrefix(request.URL.Path, "/view/" ) { |
| 226 | a.viewPage(writer, request) |
| 227 | return |
| 228 | } |
| 229 | if strings.HasPrefix(request.URL.Path, "/invite/" ) { |
| 230 | a.invitePage(writer, request) |
| 231 | return |
| 232 | } |
| 233 | if serveEmbeddedAsset(writer, request, request.URL.Path) { |
| 234 | return |
| 235 | } |
| 236 | if request.URL.Path == "/open" { |
| 237 | a.openLink(writer, request) |
| 238 | return |
| 239 | } |
| 240 | if request.URL.Path == "/manifest.webmanifest" { |
| 241 | a.manifest(writer, request) |
| 242 | return |
| 243 | } |
| 244 | if request.URL.Path == "/qr.svg" { |
| 245 | a.qrImage(writer, request.URL.Query().Get("text" )) |
| 246 | return |
| 247 | } |
| 248 | if request.URL.Path == "/card.svg" { |
| 249 | writer.Header().Set("content-type" , "image/svg+xml; charset=utf-8" ) |
| 250 | _, _ = fmt.Fprintf(writer, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 420 180"><rect width="420" height="180" fill="white"/><text x="20" y="70" font-family="sans-serif" font-size="28">%s</text><text x="20" y="112" font-family="monospace" font-size="14">%s</text></svg>` , template.HTMLEscapeString(a.backend.Policy().Name), template.HTMLEscapeString(a.backend.URL())) |
| 251 | return |
| 252 | } |
| 253 | if request.URL.Path == "/card.nostr" { |
| 254 | writer.Header().Set("content-type" , "text/plain; charset=utf-8" ) |
| 255 | _, _ = fmt.Fprintf(writer, "nostr:%s" , identityNpub(a.backend.Identity())) |
| 256 | return |
| 257 | } |
| 258 | if request.URL.Path == "/articles.json" { |
| 259 | if !a.pageFeatureEnabled(request.URL.Path) { |
| 260 | http.NotFound(writer, request) |
| 261 | return |
| 262 | } |
| 263 | a.articlesJSON(writer, request) |
| 264 | return |
| 265 | } |
| 266 | if request.URL.Path == "/feed" || request.URL.Path == "/feed.xml" { |
| 267 | if !a.pageFeatureEnabled(request.URL.Path) { |
| 268 | http.NotFound(writer, request) |
| 269 | return |
| 270 | } |
| 271 | a.feed(writer, request) |
| 272 | return |
| 273 | } |
| 274 | a.page(writer, request) |
| 275 | } |
| 276 | |
| 277 | func (a *App) jobStatus(writer http.ResponseWriter, request *http.Request) { |
| 278 | actor, err := a.resolveActor(request) |
| 279 | if err != nil || actor == "" { |
| 280 | http.Error(writer, "authentication required" , http.StatusUnauthorized) |
| 281 | return |
| 282 | } |
| 283 | writer.Header().Set("content-type" , "text/event-stream; charset=utf-8" ) |
| 284 | writer.Header().Set("cache-control" , "no-cache" ) |
| 285 | writer.Header().Set("x-content-type-options" , "nosniff" ) |
| 286 | flusher, ok := writer.(http.Flusher) |
| 287 | if !ok { |
| 288 | http.Error(writer, "streaming is unavailable" , http.StatusNotImplemented) |
| 289 | return |
| 290 | } |
| 291 | var previous string |
| 292 | writeUpdate := func() bool { |
| 293 | currentActor, actorErr := a.resolveActor(request) |
| 294 | if actorErr != nil || currentActor == "" || currentActor != actor { |
| 295 | return false |
| 296 | } |
| 297 | result, queryErr := a.backend.Query(request.Context(), "listjobs" , nil, currentActor) |
| 298 | fragment := jobsFragment(result, queryErr) |
| 299 | if fragment == previous { |
| 300 | return true |
| 301 | } |
| 302 | previous = fragment |
| 303 | _, writeErr := fmt.Fprintf(writer, "data: %s\n\n" , fragment) |
| 304 | flusher.Flush() |
| 305 | return writeErr == nil |
| 306 | } |
| 307 | if !writeUpdate() { |
| 308 | return |
| 309 | } |
| 310 | ticker := time.NewTicker(2 * time.Second) |
| 311 | defer ticker.Stop() |
| 312 | for { |
| 313 | select { |
| 314 | case <-request.Context().Done(): |
| 315 | return |
| 316 | case <-ticker.C: |
| 317 | if !writeUpdate() { |
| 318 | return |
| 319 | } |
| 320 | } |
| 321 | } |
| 322 | } |
| 323 | |
| 324 | func (a *App) joinPolicy(writer http.ResponseWriter) { |
| 325 | writeJSON(writer, http.StatusOK, map[string]string{"terms" : a.backend.Policy().JoinTerms}) |
| 326 | } |
| 327 | |
| 328 | func (a *App) claimInvite(writer http.ResponseWriter, request *http.Request) { |
| 329 | actor, err := a.resolveActor(request) |
| 330 | if err != nil || actor == "" { |
| 331 | writeJSON(writer, http.StatusUnauthorized, map[string]string{"error" : "authentication required" }) |
| 332 | return |
| 333 | } |
| 334 | var code, termsHash string |
| 335 | if strings.HasPrefix(request.Header.Get("content-type" ), "application/json" ) { |
| 336 | var body struct { |
| 337 | Params []json.RawMessage `json:"params"` |
| 338 | } |
| 339 | if err := json.NewDecoder(request.Body).Decode(&body); err != nil { |
| 340 | writeJSON(writer, http.StatusBadRequest, map[string]string{"error" : "invalid JSON request" }) |
| 341 | return |
| 342 | } |
| 343 | if len(body.Params) > 0 { |
| 344 | _ = json.Unmarshal(body.Params[0], &code) |
| 345 | } |
| 346 | if len(body.Params) > 1 { |
| 347 | _ = json.Unmarshal(body.Params[1], &termsHash) |
| 348 | } |
| 349 | } else if err := request.ParseForm(); err != nil { |
| 350 | writeJSON(writer, http.StatusBadRequest, map[string]string{"error" : "invalid form" }) |
| 351 | return |
| 352 | } else { |
| 353 | code = request.FormValue("code" ) |
| 354 | } |
| 355 | if code == "" { |
| 356 | writeJSON(writer, http.StatusBadRequest, map[string]string{"error" : "invite code is required" }) |
| 357 | return |
| 358 | } |
| 359 | params := []json.RawMessage{json.RawMessage(fmt.Sprintf("%q" , code))} |
| 360 | if termsHash != "" { |
| 361 | params = append(params, json.RawMessage(fmt.Sprintf("%q" , termsHash))) |
| 362 | } |
| 363 | result, err := a.backend.Query(request.Context(), "claiminvite" , params, actor) |
| 364 | if err != nil { |
| 365 | writeJSON(writer, http.StatusBadRequest, map[string]string{"error" : err.Error()}) |
| 366 | return |
| 367 | } |
| 368 | writeJSON(writer, http.StatusOK, map[string]any{"result" : result}) |
| 369 | } |
| 370 | |
| 371 | func (a *App) peopleJSON(writer http.ResponseWriter, request *http.Request) { |
| 372 | actor, err := a.resolveActor(request) |
| 373 | if err != nil { |
| 374 | writeJSON(writer, http.StatusUnauthorized, map[string]string{"error" : err.Error()}) |
| 375 | return |
| 376 | } |
| 377 | result, err := a.backend.Query(request.Context(), "listpeople" , nil, actor) |
| 378 | if err != nil { |
| 379 | writeJSON(writer, http.StatusForbidden, map[string]string{"error" : err.Error()}) |
| 380 | return |
| 381 | } |
| 382 | writeJSON(writer, http.StatusOK, result) |
| 383 | } |
| 384 | |
| 385 | func (a *App) connectJSON(writer http.ResponseWriter, request *http.Request) { |
| 386 | actor, actorErr := a.resolveActor(request) |
| 387 | if actorErr != nil || actor == "" { |
| 388 | writeJSON(writer, http.StatusUnauthorized, map[string]string{"error" : "authentication required" }) |
| 389 | return |
| 390 | } |
| 391 | result, err := a.backend.Query(request.Context(), "listconnections" , nil, actor) |
| 392 | if err != nil { |
| 393 | writeJSON(writer, http.StatusNotFound, map[string]string{"error" : err.Error()}) |
| 394 | return |
| 395 | } |
| 396 | if request.URL.Query().Get("catalog" ) == "" { |
| 397 | writeJSON(writer, http.StatusOK, result) |
| 398 | return |
| 399 | } |
| 400 | // The Connect page reads the list and the card catalog with its session |
| 401 | // so showing the page never asks the signer for a signature. |
| 402 | catalog, err := a.backend.Query(request.Context(), "listconnectiontemplates" , nil, actor) |
| 403 | if err != nil { |
| 404 | writeJSON(writer, http.StatusNotFound, map[string]string{"error" : err.Error()}) |
| 405 | return |
| 406 | } |
| 407 | writeJSON(writer, http.StatusOK, map[string]any{"connections" : result, "catalog" : catalog}) |
| 408 | } |
| 409 | |
| 410 | func (a *App) eventPage(writer http.ResponseWriter, request *http.Request) { |
| 411 | actor, actorErr := a.resolveActor(request) |
| 412 | if actorErr != nil { |
| 413 | http.Error(writer, "authentication required" , http.StatusUnauthorized) |
| 414 | return |
| 415 | } |
| 416 | identifier := strings.TrimPrefix(request.URL.Path, "/e/" ) |
| 417 | address := strings.HasPrefix(request.URL.Path, "/a/" ) |
| 418 | if address { |
| 419 | identifier = strings.TrimPrefix(request.URL.Path, "/a/" ) |
| 420 | } |
| 421 | if a.socialReferencePage(writer, request, identifier, address, actor) { |
| 422 | return |
| 423 | } |
| 424 | method := "eventdetail" |
| 425 | params := []json.RawMessage{json.RawMessage(fmt.Sprintf(`%q` , identifier))} |
| 426 | if address { |
| 427 | method = "queryevents" |
| 428 | params = []json.RawMessage{json.RawMessage(fmt.Sprintf(`{"#d":[%q],"limit":1}` , identifier))} |
| 429 | } |
| 430 | result, err := a.backend.Query(request.Context(), method, params, actor) |
| 431 | if err != nil { |
| 432 | http.NotFound(writer, request) |
| 433 | return |
| 434 | } |
| 435 | data := PageData{Event: result, View: identifier, Tab: "event" } |
| 436 | data.Title = "Event " + shortID(identifier) + " | " + a.backend.Slug() |
| 437 | if address { |
| 438 | data.Feed = browseRows(result) |
| 439 | } |
| 440 | a.render(writer, request, data) |
| 441 | } |
| 442 | |
| 443 | func (a *App) viewPage(writer http.ResponseWriter, request *http.Request) { |
| 444 | name := strings.TrimPrefix(request.URL.Path, "/view/" ) |
| 445 | writer.Header().Set("Cache-Control" , "private, no-store" ) |
| 446 | actor, err := a.resolveActor(request) |
| 447 | if err != nil { |
| 448 | http.Error(writer, err.Error(), http.StatusUnauthorized) |
| 449 | return |
| 450 | } |
| 451 | result, err := a.backend.Query(request.Context(), "listview" , []json.RawMessage{json.RawMessage(fmt.Sprintf(`%q` , name))}, actor) |
| 452 | if err != nil { |
| 453 | status := http.StatusForbidden |
| 454 | if strings.HasPrefix(err.Error(), "auth-required:" ) { |
| 455 | status = http.StatusUnauthorized |
| 456 | } |
| 457 | if strings.HasPrefix(err.Error(), "not found:" ) { |
| 458 | status = http.StatusNotFound |
| 459 | } |
| 460 | writeJSON(writer, status, map[string]string{"error" : err.Error()}) |
| 461 | return |
| 462 | } |
| 463 | writeJSON(writer, http.StatusOK, result) |
| 464 | } |
| 465 | |
| 466 | func (a *App) browse(writer http.ResponseWriter, request *http.Request) { |
| 467 | actor, actorErr := a.resolveActor(request) |
| 468 | if actorErr != nil { |
| 469 | http.Error(writer, "authentication required" , http.StatusUnauthorized) |
| 470 | return |
| 471 | } |
| 472 | path, method := request.URL.Path, "" |
| 473 | switch path { |
| 474 | case "/repos" : |
| 475 | method = "browserepos" |
| 476 | case "/repo" : |
| 477 | method = "browserepo" |
| 478 | case "/files" : |
| 479 | method = "browsefiles" |
| 480 | case "/file" : |
| 481 | method = "browsefile" |
| 482 | case "/approvals" : |
| 483 | method = "browseapprovals" |
| 484 | case "/profile" : |
| 485 | method = "browseprofile" |
| 486 | case "/wiki" : |
| 487 | method = "browsewiki" |
| 488 | case "/manage/health" : |
| 489 | method = "browsestatus" |
| 490 | } |
| 491 | room := roomRoute(path) |
| 492 | switch room.tab { |
| 493 | case "rooms" : |
| 494 | method = "browserooms" |
| 495 | case "room" : |
| 496 | method = "browseroom" |
| 497 | case "thread" : |
| 498 | method = "browsethread" |
| 499 | } |
| 500 | if strings.HasPrefix(path, "/wiki/" ) { |
| 501 | method = "browsewikipage" |
| 502 | } |
| 503 | params := []json.RawMessage{} |
| 504 | pageQuery := request.URL.Query() |
| 505 | query := map[string]any{"cursor" : request.URL.Query().Get("cursor" ), "limit" : 50, "q" : request.URL.Query().Get("q" )} |
| 506 | if value := request.URL.Query().Get("limit" ); value != "" { |
| 507 | if parsed, parseErr := strconv.Atoi(value); parseErr == nil && parsed > 0 { |
| 508 | query["limit" ] = parsed |
| 509 | } |
| 510 | } |
| 511 | switch method { |
| 512 | case "browsefiles" : |
| 513 | view := request.URL.Query().Get("view" ) |
| 514 | if view == "" { |
| 515 | view = "library" |
| 516 | } |
| 517 | query["view" ] = view |
| 518 | query["path" ] = request.URL.Query().Get("path" ) |
| 519 | case "browserepo" : |
| 520 | view := request.URL.Query().Get("view" ) |
| 521 | if view == "" || view == "home" { |
| 522 | view = "tree" |
| 523 | } |
| 524 | offset := 0 |
| 525 | if parsed, parseErr := strconv.Atoi(request.URL.Query().Get("offset" )); parseErr == nil && parsed >= 0 { |
| 526 | offset = parsed |
| 527 | } |
| 528 | backendView := view |
| 529 | if view == "issues" { |
| 530 | method = "browseissues" |
| 531 | backendView = "" |
| 532 | } |
| 533 | if view == "prs" { |
| 534 | method = "browsepulls" |
| 535 | backendView = "" |
| 536 | } |
| 537 | if view == "issue" { |
| 538 | method = "browseissue" |
| 539 | backendView = "" |
| 540 | } |
| 541 | if view == "pr" { |
| 542 | method = "browsepull" |
| 543 | backendView = "" |
| 544 | } |
| 545 | eventID := request.URL.Query().Get("id" ) |
| 546 | if eventID == "" && (view == "issue" || view == "pr" ) { |
| 547 | eventID = request.URL.Query().Get("path" ) |
| 548 | } |
| 549 | query = map[string]any{"owner" : request.URL.Query().Get("owner" ), "repo" : request.URL.Query().Get("repo" ), "event" : eventID, "q" : request.URL.Query().Get("q" ), "state" : request.URL.Query().Get("status" ), "label" : request.URL.Query().Get("label" ), "cursor" : request.URL.Query().Get("cursor" ), "ref" : request.URL.Query().Get("ref" ), "path" : request.URL.Query().Get("path" ), "view" : backendView, "offset" : offset, "limit" : 100} |
| 550 | case "browsefile" : |
| 551 | query = map[string]any{"hash" : request.URL.Query().Get("hash" )} |
| 552 | if query["hash" ] == "" { |
| 553 | query["hash" ] = request.URL.Query().Get("sha" ) |
| 554 | } |
| 555 | case "browseprofile" : |
| 556 | query = map[string]any{"pubkey" : actor} |
| 557 | case "browseapprovals" : |
| 558 | state := request.URL.Query().Get("state" ) |
| 559 | if state == "" { |
| 560 | state = "all" |
| 561 | } |
| 562 | query = map[string]any{"cursor" : query["cursor" ], "limit" : query["limit" ], "state" : state} |
| 563 | case "browsewiki" : |
| 564 | query["author" ] = request.URL.Query().Get("author" ) |
| 565 | case "browsewikipage" : |
| 566 | // The name travels in the query as well so the page template can |
| 567 | // offer to create a page that does not exist yet. |
| 568 | pageQuery.Set("d" , wikiPageName(path)) |
| 569 | query = map[string]any{"d" : pageQuery.Get("d" ), "author" : request.URL.Query().Get("author" ), "version" : request.URL.Query().Get("version" )} |
| 570 | case "browsestatus" : |
| 571 | query = map[string]any{} |
| 572 | case "browserooms" : |
| 573 | query = map[string]any{"cursor" : request.URL.Query().Get("cursor" ), "limit" : 100} |
| 574 | case "browseroom" , "browsethread" : |
| 575 | query = map[string]any{"id" : room.id, "event" : room.event, "cursor" : request.URL.Query().Get("cursor" ), "limit" : 50} |
| 576 | } |
| 577 | raw, _ := json.Marshal(query) |
| 578 | params = append(params, raw) |
| 579 | var tree []any |
| 580 | if method == "browserepo" && request.URL.Query().Get("view" ) == "file" { |
| 581 | tree = a.siblings(request.Context(), actor, request.URL.Query()) |
| 582 | } |
| 583 | result, err, typedRoom := a.typedRoomResult(request.Context(), room, pageQuery, actor) |
| 584 | if !typedRoom { |
| 585 | result, err = a.backend.Query(request.Context(), method, params, actor) |
| 586 | } |
| 587 | if err != nil { |
| 588 | data := PageData{Tab: browseTab(path), Error: err.Error(), Query: pageQuery, View: room.id} |
| 589 | if room.tab != "" { |
| 590 | data.Rooms = a.roomList(request.Context(), actor) |
| 591 | } |
| 592 | a.render(writer, request, data) |
| 593 | return |
| 594 | } |
| 595 | if method == "browserepo" && pageQuery.Get("view" ) == "" { |
| 596 | pageQuery.Set("view" , "tree" ) |
| 597 | } |
| 598 | if method == "browseapprovals" && pageQuery.Get("id" ) != "" { |
| 599 | // A notification opens one request; show it even when it has left |
| 600 | // the first page. |
| 601 | result = a.includeApproval(request.Context(), actor, result, pageQuery.Get("id" )) |
| 602 | } |
| 603 | data := PageData{Tab: browseTab(path), Feed: browseRows(result), Event: result, Query: pageQuery, View: room.id} |
| 604 | data.Title = browseTitle(path, pageQuery, result, a.backend.Slug()) |
| 605 | if id := pageQuery.Get("merge" ); method == "browsewikipage" && len(id) == 64 { |
| 606 | raw, _ := json.Marshal(map[string]any{"id" : id}) |
| 607 | if merge, mergeErr := a.backend.Query(request.Context(), "browsewikimerge" , []json.RawMessage{raw}, actor); mergeErr == nil { |
| 608 | data.Merge = merge |
| 609 | } else { |
| 610 | data.Notice = mergeErr.Error() |
| 611 | } |
| 612 | } |
| 613 | switch room.tab { |
| 614 | case "rooms" : |
| 615 | data.Rooms = data.Feed |
| 616 | case "room" , "thread" : |
| 617 | data.Rooms = a.roomList(request.Context(), actor) |
| 618 | data.Activity = a.chatActivity(request.Context(), actor, room.id, plainString(valueMap(valueMap(result)["root" ])["id" ])) |
| 619 | } |
| 620 | if method == "browserepo" && pageQuery.Get("view" ) == "home" { |
| 621 | data.Readme = a.readme(request.Context(), actor, pageQuery) |
| 622 | } |
| 623 | data.Tree = tree |
| 624 | a.render(writer, request, data) |
| 625 | } |
| 626 | |
| 627 | func browseTitle(path string, query url.Values, result any, slug string) string { |
| 628 | label := map[string]string{"/repos" : "Repositories" , "/files" : "Files" , "/file" : "File" , "/approvals" : "Approvals" , "/profile" : "Profile" , "/wiki" : "Wiki" , "/rooms" : "Rooms" }[path] |
| 629 | if strings.HasPrefix(path, "/wiki/" ) { |
| 630 | label = plainString(valueMap(result)["title" ]) |
| 631 | if label == "" { |
| 632 | label = query.Get("d" ) |
| 633 | } |
| 634 | label += " | wiki" |
| 635 | } |
| 636 | if room := roomRoute(path); room.tab == "room" || room.tab == "thread" { |
| 637 | label = "#" + room.id |
| 638 | if room.tab == "thread" { |
| 639 | label += " | thread" |
| 640 | } |
| 641 | } |
| 642 | if path == "/repo" { |
| 643 | label = query.Get("repo" ) |
| 644 | if label == "" { |
| 645 | label = plainString(valueMap(result)["identifier" ]) |
| 646 | } |
| 647 | if label == "" { |
| 648 | label = "Repository" |
| 649 | } |
| 650 | label += " | " + repoView(query) |
| 651 | } |
| 652 | if label == "" { |
| 653 | label = "Browse" |
| 654 | } |
| 655 | return label + " | " + slug |
| 656 | } |
| 657 | |
| 658 | // readme renders README.md from the requested ref for the repository entry |
| 659 | // page. A missing or binary README simply yields no content. |
| 660 | func (a *App) readme(ctx context.Context, actor string, query url.Values) template.HTML { |
| 661 | for _, name := range []string{"README.md" , "readme.md" , "README" } { |
| 662 | raw, _ := json.Marshal(map[string]any{"owner" : query.Get("owner" ), "repo" : query.Get("repo" ), "ref" : query.Get("ref" ), "path" : name, "view" : "file" , "limit" : 1}) |
| 663 | result, err := a.backend.Query(ctx, "browserepo" , []json.RawMessage{raw}, actor) |
| 664 | if err != nil { |
| 665 | continue |
| 666 | } |
| 667 | page := valueMap(result) |
| 668 | content, _ := page["content" ].(string) |
| 669 | if binary, _ := page["binary" ].(bool); binary || content == "" { |
| 670 | continue |
| 671 | } |
| 672 | return renderRepositoryMarkdownWith(content, query, a.blocks()) |
| 673 | } |
| 674 | return "" |
| 675 | } |
| 676 | |
| 677 | // siblings lists the directory that holds the file being previewed so the |
| 678 | // tree pane stays populated beside the file. |
| 679 | func (a *App) siblings(ctx context.Context, actor string, query url.Values) []any { |
| 680 | dir := "" |
| 681 | if slash := strings.LastIndex(query.Get("path" ), "/" ); slash >= 0 { |
| 682 | dir = query.Get("path" )[:slash] |
| 683 | } |
| 684 | raw, _ := json.Marshal(map[string]any{"owner" : query.Get("owner" ), "repo" : query.Get("repo" ), "ref" : query.Get("ref" ), "path" : dir, "view" : "tree" , "limit" : 100}) |
| 685 | result, err := a.backend.Query(ctx, "browserepo" , []json.RawMessage{raw}, actor) |
| 686 | if err != nil { |
| 687 | return nil |
| 688 | } |
| 689 | entries, _ := valueMap(result)["entries" ].([]any) |
| 690 | return entries |
| 691 | } |
| 692 | |
| 693 | func browseTab(path string) string { |
| 694 | if strings.HasPrefix(path, "/wiki/" ) { |
| 695 | return "wikipage" |
| 696 | } |
| 697 | if room := roomRoute(path); room.tab != "" { |
| 698 | return room.tab |
| 699 | } |
| 700 | switch path { |
| 701 | case "/wiki" : |
| 702 | return "wiki" |
| 703 | case "/repos" : |
| 704 | return "repos" |
| 705 | case "/repo" : |
| 706 | return "repo" |
| 707 | case "/files" : |
| 708 | return "files" |
| 709 | case "/file" : |
| 710 | return "file" |
| 711 | case "/approvals" : |
| 712 | return "approvals" |
| 713 | case "/profile" : |
| 714 | return "profile" |
| 715 | default: |
| 716 | return "health" |
| 717 | } |
| 718 | } |
| 719 | func browseRows(value any) []any { |
| 720 | encoded, err := json.Marshal(value) |
| 721 | if err != nil { |
| 722 | return nil |
| 723 | } |
| 724 | var rows []any |
| 725 | if json.Unmarshal(encoded, &rows) == nil { |
| 726 | return rows |
| 727 | } |
| 728 | var object map[string]any |
| 729 | if json.Unmarshal(encoded, &object) == nil { |
| 730 | if items, ok := object["items" ].([]any); ok { |
| 731 | return items |
| 732 | } |
| 733 | } |
| 734 | return []any{value} |
| 735 | } |
| 736 | |
| 737 | func (a *App) invitePage(writer http.ResponseWriter, request *http.Request) { |
| 738 | code := strings.TrimPrefix(request.URL.Path, "/invite/" ) |
| 739 | a.render(writer, request, PageData{Invite: code, Tab: "invite" }) |
| 740 | } |
| 741 | |
| 742 | // render fills the shared page data, applies private relay masking, and |
| 743 | // writes the page. Every HTML response goes through here. |
| 744 | func (a *App) render(writer http.ResponseWriter, request *http.Request, data PageData) { |
| 745 | actor, _ := a.resolveActor(request) |
| 746 | if data.Title == "" { |
| 747 | data.Title = a.backend.Slug() |
| 748 | } |
| 749 | data.URL, data.Slug, data.Identity, data.Policy, data.Actor, data.Base = a.backend.URL(), a.backend.Slug(), a.backend.Identity(), a.backend.Policy(), actor, requestPrefix(request) |
| 750 | data.Owner = actor != "" && actor == data.Policy.Owner |
| 751 | if actor != "" && data.Tab == "account" { |
| 752 | if preferences, ok := a.backend.(AccountPreferencesReader); ok { |
| 753 | data.SharePresence = preferences.SharePresence(request.Context(), actor) |
| 754 | } |
| 755 | } |
| 756 | data.Version, data.Revision = a.version, a.revision |
| 757 | data.Methods = supportedMethods |
| 758 | if data.Query == nil { |
| 759 | data.Query = request.URL.Query() |
| 760 | } |
| 761 | data.Path = strings.TrimPrefix(request.URL.Path, data.Base) |
| 762 | a.privatePageData(&data, request, actor) |
| 763 | if !data.Private && data.Actor == "" && (railKind(data.Tab) == "manage" || data.Tab == "profile" || data.Tab == "account" ) { |
| 764 | // Management pages and the profile editor are for signed-in people |
| 765 | // only. Guests see the sign-in page at the same address and come back |
| 766 | // after signing in. |
| 767 | data.Tab, data.Notice = "signin" , "Sign in to manage this relay." |
| 768 | if data.Path == "/profile" { |
| 769 | data.Notice = "Sign in to edit your profile." |
| 770 | } else if data.Path == "/account" { |
| 771 | data.Notice = "Sign in to open your account." |
| 772 | } |
| 773 | data.Event, data.Feed, data.Error = nil, nil, "" |
| 774 | } |
| 775 | writer.Header().Set("Cache-Control" , "private, no-store" ) |
| 776 | writer.Header().Set("content-type" , "text/html; charset=utf-8" ) |
| 777 | var rendered bytes.Buffer |
| 778 | if err := a.tmpl.ExecuteTemplate(&rendered, "page" , data); err != nil { |
| 779 | http.Error(writer, "render page: " +err.Error(), http.StatusInternalServerError) |
| 780 | return |
| 781 | } |
| 782 | _, _ = writer.Write([]byte(injectBase(rendered.String(), data.Base))) |
| 783 | } |
| 784 | |
| 785 | // manifest describes the installable app for this relay and tenant prefix. |
| 786 | func (a *App) manifest(writer http.ResponseWriter, request *http.Request) { |
| 787 | name := a.backend.Policy().Name |
| 788 | if name == "" || privatePolicyEnabled(a.backend.Policy()) { |
| 789 | name = a.backend.Slug() |
| 790 | } |
| 791 | encoded, _ := json.Marshal(name) |
| 792 | body := strings.ReplaceAll(manifestTemplate, `"NAME"` , string(encoded)) |
| 793 | body = strings.ReplaceAll(body, "BASE/" , requestPrefix(request)+"/" ) |
| 794 | // A manifest carries one set of launch colours. The page links the |
| 795 | // variant that matches the active theme so the installed app's splash |
| 796 | // screen and status bar do not flash light on a dark phone. |
| 797 | theme, background := "#f4f2ec" , "#fbfaf7" |
| 798 | if request.URL.Query().Get("theme" ) == "dark" { |
| 799 | theme, background = "#1e1d1a" , "#171614" |
| 800 | } |
| 801 | body = strings.ReplaceAll(body, "THEME" , theme) |
| 802 | body = strings.ReplaceAll(body, "BACKGROUND" , background) |
| 803 | writer.Header().Set("content-type" , "application/manifest+json; charset=utf-8" ) |
| 804 | _, _ = writer.Write([]byte(body)) |
| 805 | } |
| 806 | |
| 807 | func (a *App) qrImage(writer http.ResponseWriter, text string) { |
| 808 | if a.qr == nil || text == "" { |
| 809 | http.Error(writer, "QR generation is not configured" , http.StatusNotImplemented) |
| 810 | return |
| 811 | } |
| 812 | image, err := a.qr(text) |
| 813 | if err != nil { |
| 814 | http.Error(writer, "QR generation failed" , http.StatusInternalServerError) |
| 815 | return |
| 816 | } |
| 817 | writer.Header().Set("content-type" , "image/svg+xml; charset=utf-8" ) |
| 818 | _, _ = writer.Write(image) |
| 819 | } |
| 820 | |
| 821 | func (a *App) page(writer http.ResponseWriter, request *http.Request) { |
| 822 | tab := tabForPath(request.URL.Path) |
| 823 | actor, err := a.resolveActor(request) |
| 824 | if err != nil { |
| 825 | actor = "" |
| 826 | } |
| 827 | data := PageData{Tab: tab, Query: request.URL.Query()} |
| 828 | if tab == "inbox" || tab == "outbox" || tab == "search" || tab == "articles" || tab == "sites" { |
| 829 | feed, feedErr := a.publicFeed(request.Context(), tab, actor, request.URL.Query()) |
| 830 | if feedErr != nil { |
| 831 | data.Error = feedErr.Error() |
| 832 | } else if tab == "sites" { |
| 833 | data.Feed = a.siteRows(feed) |
| 834 | } else { |
| 835 | data.Feed = feed |
| 836 | } |
| 837 | } |
| 838 | if tab == "home" { |
| 839 | if result, err := a.backend.Query(request.Context(), "connections" , nil, actor); err == nil { |
| 840 | data.Connections = expandConnections(browseRows(result), PageData{URL: a.backend.URL(), Identity: a.backend.Identity(), Policy: a.backend.Policy(), Actor: actor}) |
| 841 | } |
| 842 | } |
| 843 | if tab == "agents" && actor != "" { |
| 844 | a.agentsPage(request, actor, &data) |
| 845 | } |
| 846 | if tab == "people" && actor != "" { |
| 847 | a.peoplePage(request, actor, &data) |
| 848 | } |
| 849 | if tab == "views" && actor != "" { |
| 850 | // The listing is owner-only; anyone else keeps the built-in controls. |
| 851 | if result, err := a.backend.Query(request.Context(), "listcustomviews" , nil, actor); err == nil { |
| 852 | data.CustomViews = browseRows(result) |
| 853 | } |
| 854 | } |
| 855 | if tab != "" { |
| 856 | data.Title = tab + " | " + a.backend.Slug() |
| 857 | } |
| 858 | a.render(writer, request, data) |
| 859 | } |
| 860 | |
| 861 | // agentsPage loads the agent list for the table and cards, then the grant |
| 862 | // and recent events of one agent: the one named by ?agent=, else the first. |
| 863 | // Both queries are gated for the owner and moderators by the backend. |
| 864 | func (a *App) agentsPage(request *http.Request, actor string, data *PageData) { |
| 865 | result, err := a.backend.Query(request.Context(), "listagents" , nil, actor) |
| 866 | if err != nil { |
| 867 | data.Error = err.Error() |
| 868 | return |
| 869 | } |
| 870 | data.Feed = browseRows(result) |
| 871 | // Callbacks are shown per agent; a failure here leaves the row empty |
| 872 | // rather than hiding the agents. |
| 873 | if callbacks, err := a.backend.Query(request.Context(), "listcallbacks" , nil, actor); err == nil { |
| 874 | data.Callbacks = browseRows(callbacks) |
| 875 | } |
| 876 | selected := request.URL.Query().Get("agent" ) |
| 877 | if selected == "" { |
| 878 | // Without a choice, show the first agent that can still publish. |
| 879 | for _, agent := range data.Feed { |
| 880 | if selected == "" || agentState(agent) == "active" { |
| 881 | selected = plainString(valueMap(agent)["pubkey" ]) |
| 882 | } |
| 883 | if agentState(agent) == "active" { |
| 884 | break |
| 885 | } |
| 886 | } |
| 887 | } |
| 888 | if selected == "" { |
| 889 | return |
| 890 | } |
| 891 | raw, _ := json.Marshal(map[string]any{"agent" : selected}) |
| 892 | detail, err := a.backend.Query(request.Context(), "browseagent" , []json.RawMessage{raw}, actor) |
| 893 | if err != nil { |
| 894 | data.Error = err.Error() |
| 895 | return |
| 896 | } |
| 897 | data.Event = detail |
| 898 | } |
| 899 | |
| 900 | // peoplePage loads the access requests for the owner and moderators. A |
| 901 | // member's request is refused by the backend, and the section stays out. |
| 902 | func (a *App) peoplePage(request *http.Request, actor string, data *PageData) { |
| 903 | result, err := a.backend.Query(request.Context(), "listjoinrequests" , nil, actor) |
| 904 | if err != nil { |
| 905 | return |
| 906 | } |
| 907 | data.Review = true |
| 908 | for _, row := range browseRows(result) { |
| 909 | if plainString(valueMap(row)["pubkey" ]) != "" { |
| 910 | data.Requests = append(data.Requests, row) |
| 911 | } |
| 912 | } |
| 913 | } |
| 914 | |
| 915 | // pendingRequests keeps the access requests that wait for a decision. |
| 916 | func pendingRequests(rows []any) []any { |
| 917 | var pending []any |
| 918 | for _, row := range rows { |
| 919 | if plainString(valueMap(row)["status" ]) == "pending" { |
| 920 | pending = append(pending, row) |
| 921 | } |
| 922 | } |
| 923 | return pending |
| 924 | } |
| 925 | |
| 926 | // decidedRequests keeps the last decided access requests, newest first as |
| 927 | // the backend lists them. |
| 928 | func decidedRequests(rows []any) []any { |
| 929 | var decided []any |
| 930 | for _, row := range rows { |
| 931 | if status := plainString(valueMap(row)["status" ]); status != "" && status != "pending" { |
| 932 | decided = append(decided, row) |
| 933 | } |
| 934 | if len(decided) == 10 { |
| 935 | break |
| 936 | } |
| 937 | } |
| 938 | return decided |
| 939 | } |
| 940 | |
| 941 | // agentState folds a grant's marks into one word for data-state: revoked |
| 942 | // covers expired grants too, since neither lets the agent publish again |
| 943 | // without a new grant. |
| 944 | func agentState(agent any) string { |
| 945 | values := valueMap(agent) |
| 946 | switch { |
| 947 | case unixSeconds(values["revoked" ]) > 0: |
| 948 | return "revoked" |
| 949 | case unixSeconds(values["expires" ]) > 0 && unixSeconds(values["expires" ]) <= time.Now().Unix(): |
| 950 | return "revoked" |
| 951 | case values["paused" ] == true: |
| 952 | return "paused" |
| 953 | default: |
| 954 | return "active" |
| 955 | } |
| 956 | } |
| 957 | |
| 958 | // agentSince is the short time shown beside the state: when the grant was |
| 959 | // revoked or expired, else the agent's newest event. |
| 960 | func agentSince(agent any) string { |
| 961 | values := valueMap(agent) |
| 962 | if revoked := unixSeconds(values["revoked" ]); revoked > 0 { |
| 963 | return short(revoked) |
| 964 | } |
| 965 | if expires := unixSeconds(values["expires" ]); expires > 0 && expires <= time.Now().Unix() { |
| 966 | return "expired " + short(expires) |
| 967 | } |
| 968 | if last := short(values["lastEvent" ]); last != "" { |
| 969 | return last |
| 970 | } |
| 971 | return "" |
| 972 | } |
| 973 | |
| 974 | // agentLabel is the grant's name, or the shortened key when it has none. |
| 975 | func agentLabel(agent any) string { |
| 976 | values := valueMap(agent) |
| 977 | if name := plainString(values["name" ]); name != "" { |
| 978 | return name |
| 979 | } |
| 980 | return shortID(plainString(values["pubkey" ])) |
| 981 | } |
| 982 | |
| 983 | // scopeList joins one list from the grant scope: rooms or kinds. |
| 984 | func scopeList(agent any, key string) string { |
| 985 | items, _ := valueMap(valueMap(agent)["scope" ])[key].([]any) |
| 986 | parts := make([]string, 0, len(items)) |
| 987 | for _, item := range items { |
| 988 | parts = append(parts, plainString(item)) |
| 989 | } |
| 990 | return strings.Join(parts, ", " ) |
| 991 | } |
| 992 | |
| 993 | // agentScope summarises a grant on one line for the agent table. |
| 994 | func agentScope(agent any) string { |
| 995 | scope := valueMap(valueMap(agent)["scope" ]) |
| 996 | var parts []string |
| 997 | if rooms := scopeList(agent, "rooms" ); rooms != "" { |
| 998 | parts = append(parts, "rooms: " +rooms) |
| 999 | } |
| 1000 | if repos, _ := scope["repos" ].([]any); len(repos) > 0 { |
| 1001 | names := make([]string, 0, len(repos)) |
| 1002 | for _, repo := range repos { |
| 1003 | values := valueMap(repo) |
| 1004 | names = append(names, plainString(values["identifier" ])+" (" +plainString(values["level" ])+")" ) |
| 1005 | } |
| 1006 | parts = append(parts, "repos: " +strings.Join(names, ", " )) |
| 1007 | } |
| 1008 | if wiki := plainString(scope["wiki" ]); wiki != "" { |
| 1009 | parts = append(parts, "wiki: " +wiki) |
| 1010 | } |
| 1011 | if kinds := scopeList(agent, "kinds" ); kinds != "" { |
| 1012 | parts = append(parts, "kinds: " +kinds) |
| 1013 | } |
| 1014 | if sites, _ := scope["sites" ].([]any); len(sites) > 0 { |
| 1015 | labels := make([]string, 0, len(sites)) |
| 1016 | for _, site := range sites { |
| 1017 | labels = append(labels, plainString(valueMap(site)["label" ])) |
| 1018 | } |
| 1019 | parts = append(parts, "sites: " +strings.Join(labels, ", " )) |
| 1020 | } |
| 1021 | if len(parts) == 0 { |
| 1022 | return "profile and relay list only" |
| 1023 | } |
| 1024 | return strings.Join(parts, " | " ) |
| 1025 | } |
| 1026 | |
| 1027 | // agentCounts tallies agents by state for the panel. |
| 1028 | func agentCounts(agents []any) map[string]int { |
| 1029 | counts := map[string]int{"active" : 0, "paused" : 0, "revoked" : 0} |
| 1030 | for _, agent := range agents { |
| 1031 | counts[agentState(agent)]++ |
| 1032 | } |
| 1033 | return counts |
| 1034 | } |
| 1035 | |
| 1036 | // callbacksFor picks the callbacks one key registered. |
| 1037 | func callbacksFor(callbacks []any, pubkey string) []any { |
| 1038 | var out []any |
| 1039 | for _, callback := range callbacks { |
| 1040 | if plainString(valueMap(callback)["owner" ]) == pubkey { |
| 1041 | out = append(out, callback) |
| 1042 | } |
| 1043 | } |
| 1044 | return out |
| 1045 | } |
| 1046 | |
| 1047 | // callbackState is active or paused; a paused callback carries the reason |
| 1048 | // in its lastStatus. |
| 1049 | func callbackState(callback any) string { |
| 1050 | if valueMap(callback)["paused" ] == true { |
| 1051 | return "paused" |
| 1052 | } |
| 1053 | return "active" |
| 1054 | } |
| 1055 | |
| 1056 | // callbackKinds lists the kinds a callback's filter names. |
| 1057 | func callbackKinds(callback any) string { |
| 1058 | kinds, _ := valueMap(valueMap(callback)["filter" ])["kinds" ].([]any) |
| 1059 | parts := make([]string, 0, len(kinds)) |
| 1060 | for _, kind := range kinds { |
| 1061 | parts = append(parts, plainString(kind)) |
| 1062 | } |
| 1063 | return strings.Join(parts, ", " ) |
| 1064 | } |
| 1065 | |
| 1066 | // callbackCounts tallies callbacks by state for the panel. |
| 1067 | func callbackCounts(callbacks []any) map[string]int { |
| 1068 | counts := map[string]int{"active" : 0, "paused" : 0} |
| 1069 | for _, callback := range callbacks { |
| 1070 | counts[callbackState(callback)]++ |
| 1071 | } |
| 1072 | return counts |
| 1073 | } |
| 1074 | |
| 1075 | // dateAfter is the date field value for a day count from today, in UTC. |
| 1076 | func dateAfter(days int) string { |
| 1077 | return time.Now().UTC().AddDate(0, 0, days).Format("2006-01-02" ) |
| 1078 | } |
| 1079 | |
| 1080 | // agentByKey finds one grant in the agents list, for the form that replaces |
| 1081 | // it. An unknown or empty key yields nil, so the form stays blank. |
| 1082 | func agentByKey(agents []any, pubkey string) any { |
| 1083 | if !hexID.MatchString(pubkey) { |
| 1084 | return nil |
| 1085 | } |
| 1086 | for _, agent := range agents { |
| 1087 | if plainString(valueMap(agent)["pubkey" ]) == pubkey { |
| 1088 | return agent |
| 1089 | } |
| 1090 | } |
| 1091 | return nil |
| 1092 | } |
| 1093 | |
| 1094 | // repoLines renders a grant's repositories one per line, the way the form |
| 1095 | // takes them: owner:identifier:level. |
| 1096 | func repoLines(agent any) string { |
| 1097 | repos, _ := valueMap(valueMap(agent)["scope" ])["repos" ].([]any) |
| 1098 | lines := make([]string, 0, len(repos)) |
| 1099 | for _, repo := range repos { |
| 1100 | r := valueMap(repo) |
| 1101 | lines = append(lines, plainString(r["owner" ])+":" +plainString(r["identifier" ])+":" +plainString(r["level" ])) |
| 1102 | } |
| 1103 | return strings.Join(lines, "\n" ) |
| 1104 | } |
| 1105 | |
| 1106 | // siteLines renders a grant's sites one per line, the way the form takes |
| 1107 | // them: the label, then ttl=<days> and encrypted when set. |
| 1108 | func siteLines(agent any) string { |
| 1109 | sites, _ := valueMap(valueMap(agent)["scope" ])["sites" ].([]any) |
| 1110 | lines := make([]string, 0, len(sites)) |
| 1111 | for _, site := range sites { |
| 1112 | s := valueMap(site) |
| 1113 | line := plainString(s["label" ]) |
| 1114 | if ttl := plainString(s["ttl" ]); ttl != "" && ttl != "0" { |
| 1115 | line += " ttl=" + ttl |
| 1116 | } |
| 1117 | if s["encrypted" ] == true { |
| 1118 | line += " encrypted" |
| 1119 | } |
| 1120 | lines = append(lines, line) |
| 1121 | } |
| 1122 | return strings.Join(lines, "\n" ) |
| 1123 | } |
| 1124 | |
| 1125 | // dateOf renders a unix time as the form's date value. |
| 1126 | func dateOf(value any) string { |
| 1127 | seconds := unixSeconds(value) |
| 1128 | if seconds <= 0 { |
| 1129 | return "" |
| 1130 | } |
| 1131 | return time.Unix(seconds, 0).UTC().Format("2006-01-02" ) |
| 1132 | } |
| 1133 | |
| 1134 | var supportedMethods = []string{ |
| 1135 | "supportedmethods" , "stats" , "getpolicy" , "setpolicy" , "listaudit" , "listviews" , "listmembers" , "listpeople" , "listjoinrequests" , "approvejoin" , "denyjoin" , "setmember" , "allowpubkey" , "unrulepubkey" , "removemember" , "createinvite" , "listinvites" , "revokeinvite" , "listclaims" , "createclaim" , "deleteclaim" , "removesubtree" , "listbannedpubkeys" , "listallowedpubkeys" , "banpubkey" , "listreports" , "resolvereport" , "banevent" , "allowevent" , "listeventsneedingmoderation" , "blockip" , "unblockip" , "listblockedips" , "exportconfig" , "importconfig" , "planconfig" , "listblobs" , "deleteblob" , "listbannedevents" , "deleteevent" , "listrecentevents" , "searchevents" , "pinevent" , "unpinevent" , "listpins" , "allowkind" , "disallowkind" , "unrulekind" , "storagestats" , "gitstorage" , "setretention" , "listretention" , "purgekind" , "listallowedkinds" , "listblockedkinds" , "notifytest" , "resetrules" , "listpresets" , "listconnectiontemplates" , "listconnections" , "setconnections" , "applypreset" , "forkrelay" , "pullfrom" , "pullstatus" , "listjobs" , "deliverystatus" , "addjob" , "removejob" , "runjob" , "backfill" , "transferowner" , "listdumps" , "deletedump" , "dumpnow" , "backupnow" , "listbackups" , "deletebackup" , "changerelayname" , "changerelaydescription" , "changerelayicon" , "adddomain" , "setdomainsite" , "checkdomain" , "removedomain" , "listdomains" , "listcallbacks" , "addcallback" , "removecallback" , "pausecallback" , "resumecallback" , |
| 1136 | } |
| 1137 | |
| 1138 | func (a *App) card(writer http.ResponseWriter) { |
| 1139 | writer.Header().Set("content-type" , "application/json; charset=utf-8" ) |
| 1140 | _ = json.NewEncoder(writer).Encode(map[string]any{"name" : a.backend.Policy().Name, "description" : a.backend.Policy().Description, "url" : a.backend.URL(), "slug" : a.backend.Slug(), "identity" : a.backend.Identity()}) |
| 1141 | } |
| 1142 | |
| 1143 | func (a *App) rpc(writer http.ResponseWriter, request *http.Request) { |
| 1144 | if a.actor == nil { |
| 1145 | http.Error(writer, "authentication is not configured" , http.StatusUnauthorized) |
| 1146 | return |
| 1147 | } |
| 1148 | actor, err := a.actor(request) |
| 1149 | if err != nil || actor == "" { |
| 1150 | http.Error(writer, "authentication required" , http.StatusUnauthorized) |
| 1151 | return |
| 1152 | } |
| 1153 | var method string |
| 1154 | var params []json.RawMessage |
| 1155 | if strings.HasPrefix(request.Header.Get("content-type" ), "application/json" ) { |
| 1156 | var body struct { |
| 1157 | Method string `json:"method"` |
| 1158 | Params []json.RawMessage `json:"params"` |
| 1159 | } |
| 1160 | if err := json.NewDecoder(request.Body).Decode(&body); err != nil { |
| 1161 | http.Error(writer, "invalid JSON request" , http.StatusBadRequest) |
| 1162 | return |
| 1163 | } |
| 1164 | method, params = body.Method, body.Params |
| 1165 | } else if err := request.ParseForm(); err != nil { |
| 1166 | http.Error(writer, "invalid form" , http.StatusBadRequest) |
| 1167 | return |
| 1168 | } else { |
| 1169 | method = request.FormValue("method" ) |
| 1170 | params = make([]json.RawMessage, 0, len(request.Form["param" ])) |
| 1171 | for _, value := range request.Form["param" ] { |
| 1172 | params = append(params, json.RawMessage(value)) |
| 1173 | } |
| 1174 | } |
| 1175 | if method == "" { |
| 1176 | http.Error(writer, "method is required" , http.StatusBadRequest) |
| 1177 | return |
| 1178 | } |
| 1179 | result, err := a.backend.Query(request.Context(), method, params, actor) |
| 1180 | if err != nil { |
| 1181 | writeJSON(writer, http.StatusBadRequest, map[string]any{"error" : err.Error()}) |
| 1182 | return |
| 1183 | } |
| 1184 | writeJSON(writer, http.StatusOK, map[string]any{"result" : result}) |
| 1185 | } |
| 1186 | |
| 1187 | func writeJSON(writer http.ResponseWriter, status int, value any) { |
| 1188 | writer.Header().Set("content-type" , "application/json; charset=utf-8" ) |
| 1189 | writer.WriteHeader(status) |
| 1190 | _ = json.NewEncoder(writer).Encode(value) |
| 1191 | } |
| 1192 | |
| 1193 | func (a *App) resolveActor(request *http.Request) (string, error) { |
| 1194 | if a.actor == nil { |
| 1195 | return "" , nil |
| 1196 | } |
| 1197 | return a.actor(request) |
| 1198 | } |
| 1199 | |
| 1200 | func pathPrefix(path string) string { |
| 1201 | parts := strings.Split(strings.Trim(path, "/" ), "/" ) |
| 1202 | if len(parts) >= 2 && parts[0] == "r" && parts[1] != "" { |
| 1203 | return "/r/" + parts[1] |
| 1204 | } |
| 1205 | return "" |
| 1206 | } |
| 1207 | |
| 1208 | func requestPrefix(request *http.Request) string { |
| 1209 | if request != nil && request.RequestURI != "" { |
| 1210 | if parsed, err := url.ParseRequestURI(request.RequestURI); err == nil { |
| 1211 | if prefix := pathPrefix(parsed.Path); prefix != "" { |
| 1212 | return prefix |
| 1213 | } |
| 1214 | } |
| 1215 | } |
| 1216 | if request == nil || request.URL == nil { |
| 1217 | return "" |
| 1218 | } |
| 1219 | return pathPrefix(request.URL.Path) |
| 1220 | } |
| 1221 | |
| 1222 | func tabForPath(path string) string { |
| 1223 | path = strings.TrimPrefix(path, pathPrefix(path)) |
| 1224 | if path == "/" || path == "" { |
| 1225 | return "home" |
| 1226 | } |
| 1227 | path = strings.Trim(path, "/" ) |
| 1228 | if strings.HasPrefix(path, "manage/" ) { |
| 1229 | path = strings.TrimPrefix(path, "manage/" ) |
| 1230 | } |
| 1231 | if strings.HasPrefix(path, "wiki/" ) { |
| 1232 | return "wikipage" |
| 1233 | } |
| 1234 | switch path { |
| 1235 | case "manage" : |
| 1236 | return "owner" |
| 1237 | case "wiki" : |
| 1238 | return "wiki" |
| 1239 | case "repos" : |
| 1240 | return "repos" |
| 1241 | case "repo" : |
| 1242 | return "repo" |
| 1243 | case "files" : |
| 1244 | return "files" |
| 1245 | case "file" : |
| 1246 | return "file" |
| 1247 | case "approvals" : |
| 1248 | return "approvals" |
| 1249 | case "profile" : |
| 1250 | return "profile" |
| 1251 | case "account" : |
| 1252 | return "account" |
| 1253 | case "signin" , "sites" : |
| 1254 | return path |
| 1255 | case "people" , "agents" , "moderation" , "rules" , "identity" , "connect" , "data" , "sync" , "views" , "health" , "owner" : |
| 1256 | return path |
| 1257 | case "inbox" , "outbox" , "private" , "chat" , "media" , "search" , "articles" , "dm" , "quiet" , "site" , "marmot" , "grasp" , "terms" : |
| 1258 | return path |
| 1259 | default: |
| 1260 | return "home" |
| 1261 | } |
| 1262 | } |