clone

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

feat(daemon): emit path addresses for pages

Dami committed f35f8df
Approval, push notice, file, folder and wiki proposal links use the
path scheme, with people written by member name when they have one.
commit
f35f8dff89e66aebe8a44ec0fd7c41a71485a6a1
parent
196be3813d45b7d852cc18e69856e0ca47cd76c3
author
Dami <dami.etoile@gmail.com>
16 files changed, +136 -48
Minternal/daemon/approvals.go18+++++++-------
Minternal/daemon/approvals_browse.go3+-
Minternal/daemon/approvals_test.go10++++----
Minternal/daemon/chat_activity.go2+-
Minternal/daemon/client_files.go23+++++++++++++----
Minternal/daemon/collaboration_approvals.go16++++++------
Minternal/daemon/collaboration_approvals_test.go6++--
Minternal/daemon/grant_approvals_test.go2+-
Minternal/daemon/member_file_access_test.go2+-
Ainternal/daemon/page_urls.go36++++++++++++++++++++++++++
Ainternal/daemon/page_urls_test.go41++++++++++++++++++++++++++++++
Minternal/daemon/webpush_test.go2+-
Minternal/daemon/webpush_triggers.go6++--
Minternal/daemon/wiki.go3+-
Minternal/daemon/wiki_approval_items.go12++++----
Minternal/daemon/wiki_test.go2+-
M internal/daemon/approvals.go +9 -9
@@ -153,19 +153,20 @@ func approvalAsked(e event.Event) []string {
153153 return pushRecipients(e)154154 }155155 156-func approvalItemFrom(e event.Event, kind string, base string) approvalItem {156+func (t *Tenant) approvalItemFrom(ctx context.Context, e event.Event, kind string) approvalItem {157157 item := approvalItem{ID: e.ID, Kind: e.Kind, Type: kind, Asker: e.PubKey, Asked: approvalAsked(e), Subject: strings.TrimSpace(event.Tag(e, "subject")), Content: e.Content, CreatedAt: e.CreatedAt, Expires: event.Expiration(e), State: "open", Room: event.Tag(e, "h"), Event: e}158158 item.Options, item.Selection, _ = nativeInteraction(e)159159 item.Freeform = event.Tag(e, "freeform") == "true"160160 item.Interaction = event.Tag(e, "interaction")161- item.About = approvalAboutFrom(e, base)161+ item.About = t.approvalAboutFrom(ctx, e)162162 return item163163 }164164 165165 // approvalAboutFrom resolves the request's a or e reference to a page. A166166 // repository coordinate opens the repository, or its issue or pull request167167 // when the root is one; any other event opens the event page.168-func approvalAboutFrom(e event.Event, base string) *approvalAbout {168+func (t *Tenant) approvalAboutFrom(ctx context.Context, e event.Event) *approvalAbout {169+ base := strings.TrimRight(t.publicURL, "/")169170 coordinate := ""170171 for _, name := range []string{"a", "A"} {171172 if value := event.Tag(e, name); value != "" {
@@ -185,9 +186,9 @@ func approvalAboutFrom(e event.Event, base string) *approvalAbout {
185186 }186187 about := &approvalAbout{Coordinate: coordinate, Event: referenced, Kind: kind}187188 if owner, identifier, ok := repoCoordinate(coordinate); ok {188- about.URL = base + "/repo?owner=" + owner + "&repo=" + identifier + "&view=home"189- if view := map[string]string{"1621": "issue", "1618": "pr"}[kind]; view != "" && referenced != "" {190- about.URL = base + "/repo?owner=" + owner + "&repo=" + identifier + "&view=" + view + "&id=" + referenced189+ about.URL = t.repoPageURL(ctx, owner, identifier)190+ if section := map[string]string{"1621": "issues", "1618": "prs"}[kind]; section != "" && referenced != "" {191+ about.URL = t.repoItemURL(ctx, owner, identifier, section, referenced)191192 }192193 } else if referenced != "" {193194 about.URL = base + "/e/" + referenced
@@ -429,7 +430,6 @@ func approvalSettle(item *approvalItem, rows []event.Event, now int64) {
429430 // what lapsed until maintenance removes them.430431 func (t *Tenant) approvalsFor(ctx context.Context, pubkey string, before *storage.EventCursor, limit int) ([]approvalItem, bool, error) {431432 now := time.Now().Unix()432- base := strings.TrimRight(t.publicURL, "/")433433 page, err := t.store.Query(ctx, event.Filter{Kinds: approvalKinds, Tags: map[string][]string{"p": {pubkey}}}, storage.QueryOptions{Access: storage.Access{PubKeys: []string{pubkey}}, Limit: limit, Before: before})434434 if err != nil {435435 return nil, false, err
@@ -440,7 +440,7 @@ func (t *Tenant) approvalsFor(ctx context.Context, pubkey string, before *storag
440440 if !ok || row.PubKey == pubkey {441441 continue442442 }443- items = append(items, approvalItemFrom(row, kind, base))443+ items = append(items, t.approvalItemFrom(ctx, row, kind))444444 }445445 answers, err := t.approvalAnswers(ctx, items, now)446446 if err != nil {
@@ -517,7 +517,7 @@ func (t *Tenant) approvalNotices(e event.Event, kind string) []pushNotice {
517517 if native && !containsString(event.TagValues(e, "mention"), recipient) {518518 continue519519 }520- notices = append(notices, pushNotice{recipient: recipient, category: pushApprovals, body: body, url: base + "/approvals?id=" + e.ID, actions: actions})520+ notices = append(notices, pushNotice{recipient: recipient, category: pushApprovals, body: body, url: base + "/approvals/" + e.ID, actions: actions})521521 }522522 return notices523523 }
M internal/daemon/approvals_browse.go +1 -2
M internal/daemon/approvals_test.go +5 -5
@@ -138,7 +138,7 @@ func TestBrowseApprovalsStatesAndAccess(t *testing.T) {
138138 if item["state"] != "open" || item["type"] != "approve" || item["subject"] != "Publish release notes 1.4" || item["asker"] != asker || item["expires"] != float64(now+3600) || item["answer"] != nil {139139 t.Fatalf("open item %v", item)140140 }141- if about := item["about"].(map[string]any); about["coordinate"] != "30617:"+owner+":notes" || !strings.HasSuffix(about["url"].(string), "/repo?owner="+owner+"&repo=notes&view=home") {141+ if about := item["about"].(map[string]any); about["coordinate"] != "30617:"+owner+":notes" || !strings.HasSuffix(about["url"].(string), "/repos/"+owner+"/notes") {142142 t.Fatalf("about %v", about)143143 }144144 item = approvalByID(items, decided.ID)
@@ -237,7 +237,7 @@ func TestBrowseApprovalsIncludesWikiProposalsAndMerges(t *testing.T) {
237237 t.Fatalf("proposal approval %v", proposalItem)238238 }239239 mergeItem := approvalByID(items, merge.ID)240- if mergeItem["type"] != approvalWikiMerge || mergeItem["state"] != "open" || !strings.Contains(mergeItem["about"].(map[string]any)["url"].(string), "?merge="+merge.ID) {240+ if mergeItem["type"] != approvalWikiMerge || mergeItem["state"] != "open" || !strings.Contains(mergeItem["about"].(map[string]any)["url"].(string), "/proposals/"+merge.ID) {241241 t.Fatalf("merge approval %v", mergeItem)242242 }243243 if got, err := approvalCall(t, tenant, member, "browseapprovals", map[string]any{}); err != nil || len(wikiItems(got, "items")) != 0 {
@@ -283,7 +283,7 @@ func TestApprovalRequestsWakeDevicesWithActionsAndCountOnBadge(t *testing.T) {
283283 284284 request := event.Event{Kind: kindComment, PubKey: asker, CreatedAt: now, Tags: [][]string{{"request", "decide"}, {"p", owner}, {"subject", "Publish release notes"}, {"expiration", strconv.FormatInt(now+3600, 10)}}, Content: "Long body"}285285 notices := tenant.pushNotices(ctx, request)286- if len(notices) != 1 || notices[0].category != pushApprovals || notices[0].recipient != owner || notices[0].body != asker[:12]+" asks: Publish release notes" || !strings.HasSuffix(notices[0].url, "/approvals?id="+request.ID) {286+ if len(notices) != 1 || notices[0].category != pushApprovals || notices[0].recipient != owner || notices[0].body != asker[:12]+" asks: Publish release notes" || !strings.HasSuffix(notices[0].url, "/approvals/"+request.ID) {287287 t.Fatalf("decision notices %+v", notices)288288 }289289 if actions := notices[0].actions; len(actions) != 3 || actions[0].Action != "approve" || actions[1].Action != "deny" || actions[2].Action != "reply" || actions[0].Title != "Approve" {
@@ -348,7 +348,7 @@ func TestNativeFreeformAllowsBothOptionsAndCustomText(t *testing.T) {
348348 if selection != "text" {349349 e.Tags = append(e.Tags, []string{"option", "c0", "Continue"})350350 }351- item := approvalItemFrom(e, "question", "")351+ item := (&Tenant{}).approvalItemFrom(context.Background(), e, "question")352352 answer := event.Event{Kind: 1111, CreatedAt: 110, Tags: [][]string{{"h", "lab"}, {"e", e.ID}, {"E", e.ID}, {"p", e.PubKey}, {"P", e.PubKey}, {"k", "9"}, {"K", "9"}}}353353 valid := []string{`{"text":"custom"}`, "c0"}354354 invalid := []string{`{"text":"custom","extra":true}`, `{"Text":"custom"}`, `{"text":"one","text":"two"}`, "unknown", `[]`}
@@ -405,7 +405,7 @@ func TestNativeFreeformAcceptsChoiceAndExplanationTogether(t *testing.T) {
405405 e := base406406 e.Tags = append([][]string(nil), base.Tags...)407407 e.Tags = append(e.Tags, []string{"selection", test.selection})408- item := approvalItemFrom(e, "question", "")408+ item := (&Tenant{}).approvalItemFrom(context.Background(), e, "question")409409 answer := event.Event{Kind: kindComment, CreatedAt: 110, Content: test.content, Tags: answerTags}410410 if got := nativeAnswerValid(item, answer); got != test.valid {411411 t.Fatalf("nativeAnswerValid = %v, want %v for %s", got, test.valid, test.content)
M internal/daemon/chat_activity.go +1 -1
M internal/daemon/client_files.go +18 -5
@@ -291,15 +291,15 @@ func (t *Tenant) folderItems(rows []map[string]any, excluded map[string]bool, q
291291 m["name"] = path.Base(p)292292 }293293 m["kind"] = "file"294- m["open"] = "/file?hash=" + url.QueryEscape(m["sha256"].(string))294+ m["open"] = "/file/" + url.PathEscape(m["sha256"].(string))295295 if contextName, ok := m["context"].(string); ok {296- m["context_url"] = "/files?view=" + contextName + "s&path=" + url.QueryEscape(path.Dir(p))296+ m["context_url"] = filesPageURL(contextName+"s", path.Dir(p))297297 }298298 files = append(files, m)299299 }300300 items := []map[string]any{}301301 for n := range folders {302- items = append(items, map[string]any{"kind": "folder", "name": n, "path": strings.Trim(prefix+n, "/"), "open": "/files?view=" + url.QueryEscape(view) + "&path=" + url.QueryEscape(strings.Trim(prefix+n, "/"))})302+ items = append(items, map[string]any{"kind": "folder", "name": n, "path": strings.Trim(prefix+n, "/"), "open": filesPageURL(view, strings.Trim(prefix+n, "/"))})303303 }304304 items = append(items, files...)305305 sort.Slice(items, func(i, j int) bool { return fileItemKey(items[i]) < fileItemKey(items[j]) })
@@ -326,7 +326,7 @@ func fileItemKey(item map[string]any) string {
326326 }327327 func breadcrumbs(view, p string) []map[string]string {328328 label := map[string]string{"library": "My files", "sites": "Sites", "rooms": "Rooms", "storage": "Storage"}[view]329- out := []map[string]string{{"name": label, "open": "/files?view=" + view}}329+ out := []map[string]string{{"name": label, "open": filesPageURL(view, "")}}330330 parts := strings.Split(strings.Trim(p, "/"), "/")331331 cur := ""332332 for _, v := range parts {
@@ -337,11 +337,24 @@ func breadcrumbs(view, p string) []map[string]string {
337337 cur += "/"338338 }339339 cur += v340- out = append(out, map[string]string{"name": v, "open": "/files?view=" + view + "&path=" + url.QueryEscape(cur)})340+ out = append(out, map[string]string{"name": v, "open": filesPageURL(view, cur)})341341 }342342 return out343343 }344344 345+// filesPageURL is the files page for a view and folder. The library is the346+// bare /files page; the other views are /files/{view}.347+func filesPageURL(view, folder string) string {348+ page := "/files"349+ if view != "library" {350+ page += "/" + url.PathEscape(view)351+ }352+ if folder == "" || folder == "." {353+ return page354+ }355+ return page + "?path=" + url.QueryEscape(folder)356+}357+345358 func (t *Tenant) contextHashes(ctx context.Context, actor string) (map[string][]string, map[string][]string, error) {346359 sitesH := map[string][]string{}347360 roomsH := map[string][]string{}
M internal/daemon/collaboration_approvals.go +8 -8
@@ -284,7 +284,7 @@ func (t *Tenant) collaborationProposalNotices(ctx context.Context, e event.Event
284284 case event.KIND_GIT_PATCH:285285 what, subject = "patch", collaborationTitle(e)286286 }287- notice := pushNotice{category: pushApprovals, body: name + " proposes " + what + ": " + excerpt(subject), url: t.collaborationProposalURL(e, r)}287+ notice := pushNotice{category: pushApprovals, body: name + " proposes " + what + ": " + excerpt(subject), url: t.collaborationProposalURL(ctx, e, r)}288288 var notices []pushNotice289289 for _, recipient := range uniqueStrings(t.maintainerKeys(ctx, r)) {290290 if recipient == e.PubKey {
@@ -300,23 +300,23 @@ func (t *Tenant) collaborationProposalNotices(ctx context.Context, e event.Event
300300 // collaborationProposalURL is the page a proposal is read on: the issue or301301 // pull request page for a root and for a comment under one, and the event302302 // page otherwise.303-func (t *Tenant) collaborationProposalURL(e event.Event, r gitrelay.Repository) string {303+func (t *Tenant) collaborationProposalURL(ctx context.Context, e event.Event, r gitrelay.Repository) string {304304 base := strings.TrimRight(t.publicURL, "/")305- page := func(view, id string) string {306- return base + "/repo?owner=" + r.Owner + "&repo=" + r.Identifier + "&view=" + view + "&id=" + id305+ page := func(section, id string) string {306+ return t.repoItemURL(ctx, r.Owner, r.Identifier, section, id)307307 }308308 switch e.Kind {309309 case event.KIND_GIT_ISSUE:310- return page("issue", e.ID)310+ return page("issues", e.ID)311311 case event.KIND_GIT_PR:312- return page("pr", e.ID)312+ return page("prs", e.ID)313313 case 1111:314314 if root := event.Tag(e, "E"); len(root) == 64 {315315 switch event.Tag(e, "K") {316316 case "1621":317- return page("issue", root)317+ return page("issues", root)318318 case "1618":319- return page("pr", root)319+ return page("prs", root)320320 }321321 }322322 }
M internal/daemon/collaboration_approvals_test.go +3 -3
@@ -391,7 +391,7 @@ func TestRepositoryProposalWakesOwnerAndMaintainerDevices(t *testing.T) {
391391 if err := json.Unmarshal([]byte(payload), &notice); err != nil {392392 t.Fatal(err)393393 }394- if notice.Kind != pushApprovals || notice.Text != "helper proposes issue: Needs review" || notice.URL != "http://relay.test/repo?owner="+k.owner+"&repo="+proposalRepo+"&view=issue&id="+issue.ID {394+ if notice.Kind != pushApprovals || notice.Text != "helper proposes issue: Needs review" || notice.URL != "http://relay.test/repos/"+k.owner+"/"+proposalRepo+"/issues/"+issue.ID {395395 t.Fatalf("notice %+v", notice)396396 }397397 }
@@ -404,11 +404,11 @@ func TestRepositoryProposalWakesOwnerAndMaintainerDevices(t *testing.T) {
404404 // are read directly since the approvals category coalesces.405405 comment := proposalComment(t, tenant, k, testAgentSecret, issue, "Also this line looks wrong", now-70)406406 notices, ok := tenant.collaborationProposalNotices(ctx, comment)407- if !ok || len(notices) != 3 || notices[0].recipient != k.owner || notices[0].category != pushApprovals || notices[0].body != "helper proposes comment: Also this line looks wrong" || notices[0].url != "http://relay.test/repo?owner="+k.owner+"&repo="+proposalRepo+"&view=issue&id="+issue.ID {407+ if !ok || len(notices) != 3 || notices[0].recipient != k.owner || notices[0].category != pushApprovals || notices[0].body != "helper proposes comment: Also this line looks wrong" || notices[0].url != "http://relay.test/repos/"+k.owner+"/"+proposalRepo+"/issues/"+issue.ID {408408 t.Fatalf("comment notices %v %+v", ok, notices)409409 }410410 pull := proposalRoot(t, tenant, k, testAgentSecret, event.KIND_GIT_PR, "Add tests", now-60)411- if notices, ok = tenant.collaborationProposalNotices(ctx, pull); !ok || notices[0].body != "helper proposes pull request: Add tests" || notices[0].url != "http://relay.test/repo?owner="+k.owner+"&repo="+proposalRepo+"&view=pr&id="+pull.ID {411+ if notices, ok = tenant.collaborationProposalNotices(ctx, pull); !ok || notices[0].body != "helper proposes pull request: Add tests" || notices[0].url != "http://relay.test/repos/"+k.owner+"/"+proposalRepo+"/prs/"+pull.ID {412412 t.Fatalf("pull notices %v %+v", ok, notices)413413 }414414 if _, ok = tenant.collaborationProposalNotices(ctx, proposalRoot(t, tenant, k, testOwnerSecret, event.KIND_GIT_PR, "Owner pull", now-50)); ok {
M internal/daemon/grant_approvals_test.go +1 -1
M internal/daemon/member_file_access_test.go +1 -1
A internal/daemon/page_urls.go +36 -0
A internal/daemon/page_urls_test.go +41 -0
@@ -0,0 +1,41 @@
1+package daemon2+3+import (4+ "context"5+ "strings"6+ "testing"7+8+ "github.com/FelineStateMachine/tinyrelay/internal/event"9+)10+11+func TestRepoURLsUseMemberNameWhenPresent(t *testing.T) {12+ _, tenant := testTenant(t)13+ ctx := context.Background()14+ owner := tenant.Policy().Owner15+ named := wikiKey(t, testMemberSecret)16+ wikiMember(t, tenant, named)17+ base := strings.TrimRight(tenant.publicURL, "/")18+ // The owner has no member name, so the hex key stays in the path.19+ if got := tenant.repoPageURL(ctx, owner, "notes"); got != base+"/repos/"+owner+"/notes" {20+ t.Fatalf("owner repo url %q", got)21+ }22+ // A named member is written by name, escaped for the path.23+ if got := tenant.repoPageURL(ctx, named, "my notes"); got != base+"/repos/member-"+named[:8]+"/my%20notes" {24+ t.Fatalf("member repo url %q", got)25+ }26+ if got := tenant.repoItemURL(ctx, named, "notes", "issues", strings.Repeat("1", 64)); got != base+"/repos/member-"+named[:8]+"/notes/issues/"+strings.Repeat("1", 64) {27+ t.Fatalf("member issue url %q", got)28+ }29+ // A request about a pull request in the member's repository resolves30+ // the same way.31+ pull := strings.Repeat("2", 64)32+ e := event.Event{Kind: kindComment, Tags: [][]string{{"request", "approve"}, {"a", "30617:" + named + ":notes"}, {"E", pull}, {"K", "1618"}}}33+ if about := tenant.approvalAboutFrom(ctx, e); about == nil || about.URL != base+"/repos/member-"+named[:8]+"/notes/prs/"+pull {34+ t.Fatalf("about %+v", about)35+ }36+ // A stranger with no membership is written as hex too.37+ stranger := strings.Repeat("f", 64)38+ if got := tenant.repoPageURL(ctx, stranger, "notes"); got != base+"/repos/"+stranger+"/notes" {39+ t.Fatalf("stranger repo url %q", got)40+ }41+}
M internal/daemon/webpush_test.go +1 -1
M internal/daemon/webpush_triggers.go +3 -3
M internal/daemon/wiki.go +1 -2
M internal/daemon/wiki_approval_items.go +6 -6
@@ -53,7 +53,7 @@ func (t *Tenant) wikiProposalApprovalItems(ctx context.Context, actor string, de
5353 continue5454 }5555 version := wikiVersionFrom(row, false)56- item := approvalItemFrom(row, approvalWikiProposal, strings.TrimRight(t.publicURL, "/"))56+ item := t.approvalItemFrom(ctx, row, approvalWikiProposal)5757 item.Asked = asked5858 item.Subject = version.Title5959 item.About = &approvalAbout{Coordinate: version.Coordinate, Event: row.ID, Kind: "30818", URL: strings.TrimRight(t.publicURL, "/") + "/wiki/" + url.PathEscape(version.D) + "?version=" + url.QueryEscape(row.ID)}
@@ -91,10 +91,10 @@ func (t *Tenant) wikiMergeApprovalItems(ctx context.Context, actor string, befor
9191 if merge.Destination != actor {9292 continue9393 }94- item := approvalItemFrom(row, approvalWikiMerge, strings.TrimRight(t.publicURL, "/"))94+ item := t.approvalItemFrom(ctx, row, approvalWikiMerge)9595 item.Asked = []string{actor}9696 item.Subject = "Wiki merge request"97- item.About = &approvalAbout{Coordinate: merge.Target, Event: row.ID, Kind: "818", URL: strings.TrimRight(t.publicURL, "/") + "/wiki/" + url.PathEscape(merge.TargetD) + "?merge=" + row.ID}97+ item.About = &approvalAbout{Coordinate: merge.Target, Event: row.ID, Kind: "818", URL: t.wikiProposalURL(merge.TargetD, row.ID)}9898 items = append(items, item)9999 }100100 answers, err := t.approvalAnswers(ctx, items, nowUnix())
@@ -183,7 +183,7 @@ func (t *Tenant) wikiApprovalByID(ctx context.Context, actor, id string) (approv
183183 return approvalItem{}, false, nil184184 }185185 version := wikiVersionFrom(row, false)186- item := approvalItemFrom(row, approvalWikiProposal, strings.TrimRight(t.publicURL, "/"))186+ item := t.approvalItemFrom(ctx, row, approvalWikiProposal)187187 item.Asked = t.wikiApprovalAudience(ctx)188188 item.Subject = version.Title189189 item.About = &approvalAbout{Coordinate: version.Coordinate, Event: row.ID, Kind: "30818", URL: strings.TrimRight(t.publicURL, "/") + "/wiki/" + url.PathEscape(version.D) + "?version=" + url.QueryEscape(row.ID)}
@@ -193,10 +193,10 @@ func (t *Tenant) wikiApprovalByID(ctx context.Context, actor, id string) (approv
193193 if merge.Destination == "" || merge.Destination != actor {194194 return approvalItem{}, false, nil195195 }196- item := approvalItemFrom(row, approvalWikiMerge, strings.TrimRight(t.publicURL, "/"))196+ item := t.approvalItemFrom(ctx, row, approvalWikiMerge)197197 item.Asked = []string{actor}198198 item.Subject = "Wiki merge request"199- item.About = &approvalAbout{Coordinate: merge.Target, Event: row.ID, Kind: "818", URL: strings.TrimRight(t.publicURL, "/") + "/wiki/" + url.PathEscape(merge.TargetD) + "?merge=" + url.QueryEscape(row.ID)}199+ item.About = &approvalAbout{Coordinate: merge.Target, Event: row.ID, Kind: "818", URL: t.wikiProposalURL(merge.TargetD, row.ID)}200200 return item, true, nil201201 }202202
M internal/daemon/wiki_test.go +1 -1