diff --git a/appview/middleware/canonicalize_test.go b/appview/middleware/canonicalize_test.go new file mode 100644 index 00000000..d6403169 --- /dev/null +++ b/appview/middleware/canonicalize_test.go @@ -0,0 +1,136 @@ +package middleware + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/bluesky-social/indigo/atproto/identity" + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/go-chi/chi/v5" + "tangled.org/core/appview/models" +) + +func runCanonicalize(t *testing.T, method, urlPath, urlUser, urlRepo, handle string, repo *models.Repo) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(method, urlPath, nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("user", urlUser) + rctx.URLParams.Add("repo", urlRepo) + ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) + id := identity.Identity{ + DID: syntax.DID("did:plc:boltless"), + Handle: syntax.Handle(handle), + } + ctx = context.WithValue(ctx, "resolvedId", id) + ctx = context.WithValue(ctx, "repo", repo) + req = req.WithContext(ctx) + + rec := httptest.NewRecorder() + called := false + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusOK) + }) + mw := Middleware{} + mw.CanonicalizeRepoURL()(next).ServeHTTP(rec, req) + if rec.Code == http.StatusFound { + if called { + t.Errorf("middleware both issued 302 and invoked next handler") + } + } + return rec +} + +func TestCanonicalize_CanonicalUrlPassesThrough(t *testing.T) { + repo := &models.Repo{Did: "did:plc:boltless", Name: "anemone", Rkey: "anemone"} + rec := runCanonicalize(t, "GET", "/boltless.dev/anemone/issues", "boltless.dev", "anemone", "boltless.dev", repo) + if rec.Code != http.StatusOK { + t.Errorf("canonical URL got %d, want 200; Location=%q", rec.Code, rec.Header().Get("Location")) + } +} + +func TestCanonicalize_EmptyNameUsesRkeyAsSlug(t *testing.T) { + repo := &models.Repo{Did: "did:plc:boltless", Name: "", Rkey: "anemone"} + rec := runCanonicalize(t, "GET", "/boltless.dev/anemone/pulls", "boltless.dev", "anemone", "boltless.dev", repo) + if rec.Code != http.StatusOK { + t.Errorf("rkey-as-slug canonical URL got %d, want 200; Location=%q", rec.Code, rec.Header().Get("Location")) + } +} + +func TestCanonicalize_EmptyNameOwnerDidRedirectsToHandleRkey(t *testing.T) { + repo := &models.Repo{Did: "did:plc:boltless", Name: "", Rkey: "anemone"} + rec := runCanonicalize(t, "GET", "/did:plc:boltless/anemone/pulls", "did:plc:boltless", "anemone", "boltless.dev", repo) + if rec.Code != http.StatusFound { + t.Fatalf("got %d, want 302", rec.Code) + } + if got, want := rec.Header().Get("Location"), "/boltless.dev/anemone/pulls"; got != want { + t.Errorf("Location = %q, want %q", got, want) + } +} + +func TestCanonicalize_HandleSlashTIDRedirectsToName(t *testing.T) { + repo := &models.Repo{Did: "did:plc:boltless", Name: "anemone", Rkey: "3kabcxyz"} + rec := runCanonicalize(t, "GET", "/boltless.dev/3kabcxyz/issues", "boltless.dev", "3kabcxyz", "boltless.dev", repo) + if rec.Code != http.StatusFound { + t.Fatalf("got %d, want 302", rec.Code) + } + if got, want := rec.Header().Get("Location"), "/boltless.dev/anemone/issues"; got != want { + t.Errorf("Location = %q, want %q", got, want) + } +} + +func TestCanonicalize_OwnerDidRedirectsToHandle(t *testing.T) { + repo := &models.Repo{Did: "did:plc:boltless", Name: "anemone", Rkey: "anemone"} + rec := runCanonicalize(t, "GET", "/did:plc:boltless/anemone/pulls/3", "did:plc:boltless", "anemone", "boltless.dev", repo) + if rec.Code != http.StatusFound { + t.Fatalf("got %d, want 302", rec.Code) + } + if got, want := rec.Header().Get("Location"), "/boltless.dev/anemone/pulls/3"; got != want { + t.Errorf("Location = %q, want %q", got, want) + } +} + +func TestCanonicalize_OwnerDidAndTIDRedirectsToCanonical(t *testing.T) { + repo := &models.Repo{Did: "did:plc:boltless", Name: "anemone", Rkey: "3kabcxyz"} + rec := runCanonicalize(t, "GET", "/did:plc:boltless/3kabcxyz", "did:plc:boltless", "3kabcxyz", "boltless.dev", repo) + if rec.Code != http.StatusFound { + t.Fatalf("got %d, want 302", rec.Code) + } + if got, want := rec.Header().Get("Location"), "/boltless.dev/anemone"; got != want { + t.Errorf("Location = %q, want %q", got, want) + } +} + +func TestCanonicalize_PreservesQueryString(t *testing.T) { + repo := &models.Repo{Did: "did:plc:boltless", Name: "anemone", Rkey: "anemone"} + rec := runCanonicalize(t, "GET", "/did:plc:boltless/anemone/issues?state=closed&page=2", "did:plc:boltless", "anemone", "boltless.dev", repo) + if got, want := rec.Header().Get("Location"), "/boltless.dev/anemone/issues?state=closed&page=2"; got != want { + t.Errorf("Location = %q, want %q", got, want) + } +} + +func TestCanonicalize_PostNotRedirected(t *testing.T) { + repo := &models.Repo{Did: "did:plc:boltless", Name: "anemone", Rkey: "anemone"} + rec := runCanonicalize(t, "POST", "/did:plc:boltless/anemone/issues", "did:plc:boltless", "anemone", "boltless.dev", repo) + if rec.Code != http.StatusOK { + t.Errorf("POST on non-canonical URL got %d, want 200; Location=%q", rec.Code, rec.Header().Get("Location")) + } +} + +func TestCanonicalize_InvalidHandlePassesThrough(t *testing.T) { + repo := &models.Repo{Did: "did:plc:boltless", Name: "anemone", Rkey: "anemone"} + rec := runCanonicalize(t, "GET", "/did:plc:boltless/anemone", "did:plc:boltless", "anemone", string(syntax.HandleInvalid), repo) + if rec.Code != http.StatusOK { + t.Errorf("invalid handle got %d, want 200; Location=%q", rec.Code, rec.Header().Get("Location")) + } +} + +func TestCanonicalize_DotGitSuffixStripped(t *testing.T) { + repo := &models.Repo{Did: "did:plc:boltless", Name: "anemone", Rkey: "anemone"} + rec := runCanonicalize(t, "GET", "/boltless.dev/anemone.git/", "boltless.dev", "anemone.git", "boltless.dev", repo) + if rec.Code != http.StatusOK { + t.Errorf(".git on canonical name got %d, want 200; Location=%q", rec.Code, rec.Header().Get("Location")) + } +} diff --git a/appview/middleware/middleware.go b/appview/middleware/middleware.go index 0f59a4e9..207b764d 100644 --- a/appview/middleware/middleware.go +++ b/appview/middleware/middleware.go @@ -17,6 +17,7 @@ import ( "github.com/go-chi/chi/v5" "tangled.org/core/appview/cache" "tangled.org/core/appview/db" + "tangled.org/core/appview/models" "tangled.org/core/appview/oauth" "tangled.org/core/appview/pages" "tangled.org/core/appview/pagination" @@ -234,8 +235,7 @@ func (mw Middleware) ResolveRepo() middlewareFunc { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { l := mw.logger.With("middleware", "ResolveRepo") - repoName := chi.URLParam(req, "repo") - repoName = strings.TrimSuffix(repoName, ".git") + repoName := strings.TrimSuffix(chi.URLParam(req, "repo"), ".git") rkey := strings.ToLower(repoName) id, ok := req.Context().Value("resolvedId").(identity.Identity) @@ -245,60 +245,21 @@ func (mw Middleware) ResolveRepo() middlewareFunc { return } - repo, err := db.GetRepo( - mw.db, - orm.FilterEq("did", id.DID.String()), - orm.FilterEq("rkey", rkey), - ) - if err != nil { - if !errors.Is(err, sql.ErrNoRows) { - l.Error("failed to resolve repo", "err", err) - http.Error(w, "internal server error", http.StatusInternalServerError) - return - } - hint, hintErr := db.LookupRepoRename(mw.db, id.DID.String(), rkey) - if hintErr != nil && !errors.Is(hintErr, sql.ErrNoRows) { - l.Error("failed to lookup repo rename hint", "err", hintErr) - } - if hint != nil { - parts := strings.SplitN(strings.TrimPrefix(req.URL.Path, "/"), "/", 3) - target := "/" + parts[0] + "/" + hint.Rkey - if len(parts) == 3 { - target += "/" + parts[2] - } - if req.URL.RawQuery != "" { - target += "?" + req.URL.RawQuery - } - http.Redirect(w, req, target, http.StatusMovedPermanently) - return - } - nameRepos, nameErr := db.GetRepos( - mw.db, - orm.FilterEq("did", id.DID.String()), - orm.FilterEq("name", repoName), - ) - if nameErr == nil && len(nameRepos) == 1 && nameRepos[0].RepoDid != "" { - nameRepo := &nameRepos[0] - if _, tidErr := syntax.ParseTID(nameRepo.Rkey); tidErr == nil { - ctx := context.WithValue(req.Context(), "repo", nameRepo) - next.ServeHTTP(w, req.WithContext(ctx)) - return - } - parts := strings.SplitN(strings.TrimPrefix(req.URL.Path, "/"), "/", 3) - target := "/" + nameRepo.RepoDid - if len(parts) == 3 { - target += "/" + parts[2] - } - if req.URL.RawQuery != "" { - target += "?" + req.URL.RawQuery - } - http.Redirect(w, req, target, http.StatusFound) - return - } + repo, isRename := resolveRepoForOwner(mw.db, id.DID.String(), repoName, rkey, l) + if repo == nil { w.WriteHeader(http.StatusNotFound) mw.pages.ErrorKnot404(w) return } + if isRename { + handle := id.Handle.String() + if id.Handle.IsInvalidHandle() || handle == "" { + handle = id.DID.String() + } + target := reporesolver.CanonicalRedirectTarget(req, reporesolver.CanonicalRepoPath(handle, repo)) + http.Redirect(w, req, target, http.StatusMovedPermanently) + return + } ctx := context.WithValue(req.Context(), "repo", repo) next.ServeHTTP(w, req.WithContext(ctx)) @@ -306,6 +267,66 @@ func (mw Middleware) ResolveRepo() middlewareFunc { } } +func resolveRepoForOwner(d db.Execer, ownerDid, repoName, rkey string, l *slog.Logger) (*models.Repo, bool) { + repo, err := db.GetRepo(d, orm.FilterEq("did", ownerDid), orm.FilterEq("rkey", rkey)) + if err == nil { + return repo, false + } + if !errors.Is(err, sql.ErrNoRows) { + l.Error("failed to resolve repo by rkey", "err", err) + return nil, false + } + + hint, hintErr := db.LookupRepoRename(d, ownerDid, rkey) + if hintErr != nil && !errors.Is(hintErr, sql.ErrNoRows) { + l.Error("failed to lookup repo rename hint", "err", hintErr) + } + if hint != nil { + return hint, true + } + + nameRepos, nameErr := db.GetRepos(d, orm.FilterEq("did", ownerDid), orm.FilterEq("name", repoName)) + if nameErr != nil { + l.Error("failed to resolve repo by name", "err", nameErr) + return nil, false + } + if len(nameRepos) == 1 { + return &nameRepos[0], false + } + return nil, false +} + +func (mw Middleware) CanonicalizeRepoURL() middlewareFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet && req.Method != http.MethodHead { + next.ServeHTTP(w, req) + return + } + id, idOk := req.Context().Value("resolvedId").(identity.Identity) + repo, repoOk := req.Context().Value("repo").(*models.Repo) + if !idOk || !repoOk || id.Handle.IsInvalidHandle() { + next.ServeHTTP(w, req) + return + } + handle := id.Handle.String() + if handle == "" { + next.ServeHTTP(w, req) + return + } + canonical := reporesolver.CanonicalRepoPath(handle, repo) + urlUser := chi.URLParam(req, "user") + urlRepo := strings.TrimSuffix(chi.URLParam(req, "repo"), ".git") + if urlUser+"/"+urlRepo == canonical { + next.ServeHTTP(w, req) + return + } + + http.Redirect(w, req, reporesolver.CanonicalRedirectTarget(req, canonical), http.StatusFound) + }) + } +} + // middleware that is tacked on top of /{user}/{repo}/pulls/{pull} func (mw Middleware) ResolvePull() middlewareFunc { return func(next http.Handler) http.Handler { @@ -408,14 +429,21 @@ func (mw Middleware) GoImport() middlewareFunc { if strings.Contains(modulePath, ":") { modulePath = userutil.FlattenDid(f.Did) + "/" + f.Rkey } - html := fmt.Sprintf( - ` -`, - modulePath, fullName, - modulePath, fullName, - ) + tags := []string{ + fmt.Sprintf(``, modulePath, fullName), + fmt.Sprintf(``, modulePath, fullName), + } + if f.RepoDid != "" { + stable := userutil.FlattenDid(f.RepoDid) + if stable != modulePath { + tags = append(tags, + fmt.Sprintf(``, stable, f.RepoDid), + fmt.Sprintf(``, stable, f.RepoDid), + ) + } + } w.Header().Set("Content-Type", "text/html") - w.Write([]byte(html)) + w.Write([]byte(strings.Join(tags, "\n"))) return } } diff --git a/appview/models/repo.go b/appview/models/repo.go index 02755523..02afc6f4 100644 --- a/appview/models/repo.go +++ b/appview/models/repo.go @@ -80,6 +80,13 @@ func (r Repo) RepoAt() syntax.ATURI { return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.Did, tangled.RepoNSID, r.Rkey)) } +func (r Repo) Slug() string { + if r.Name != "" { + return r.Name + } + return r.Rkey +} + func (r Repo) RepoIdentifier() string { if r.RepoDid != "" { return r.RepoDid diff --git a/appview/models/repo_test.go b/appview/models/repo_test.go index f56d95c2..d534ceb7 100644 --- a/appview/models/repo_test.go +++ b/appview/models/repo_test.go @@ -84,3 +84,23 @@ func TestCosmeticName_PresentWhenDiffers(t *testing.T) { t.Errorf("cosmeticName = %q, want %q", *rec.Name, "MyRepo") } } + +func TestRepoSlug(t *testing.T) { + cases := []struct { + name string + repo Repo + want string + }{ + {"name set distinct from rkey", Repo{Name: "anemone", Rkey: "3kabc"}, "anemone"}, + {"name equals rkey", Repo{Name: "scallop", Rkey: "scallop"}, "scallop"}, + {"name empty falls to rkey", Repo{Name: "", Rkey: "whelk"}, "whelk"}, + {"both empty", Repo{}, ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := c.repo.Slug(); got != c.want { + t.Errorf("Slug() = %q, want %q", got, c.want) + } + }) + } +} diff --git a/appview/pages/funcmap.go b/appview/pages/funcmap.go index 5340ca5a..f1d6b6e2 100644 --- a/appview/pages/funcmap.go +++ b/appview/pages/funcmap.go @@ -88,7 +88,7 @@ func (p *Pages) funcMap() template.FuncMap { } handle := ownerId.Handle if handle != "" && !handle.IsInvalidHandle() { - return string(handle) + "/" + repo.Name + return string(handle) + "/" + repo.Slug() } return repo.RepoIdentifier() }, diff --git a/appview/pages/ratchet_test.go b/appview/pages/ratchet_test.go new file mode 100644 index 00000000..635042a0 --- /dev/null +++ b/appview/pages/ratchet_test.go @@ -0,0 +1,92 @@ +package pages + +import ( + "io/fs" + "regexp" + "strings" + "testing" +) + +var repoRkeyAllowlist = map[string]bool{ + "templates/repo/settings/sites.html": true, +} + +var repoRkeyPattern = regexp.MustCompile(`\.(Repo|RepoInfo)\.Rkey\b`) + +func TestNoRepoRkeyInTemplates(t *testing.T) { + err := fs.WalkDir(Files, "templates", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || !strings.HasSuffix(path, ".html") { + return nil + } + if repoRkeyAllowlist[path] { + return nil + } + data, err := fs.ReadFile(Files, path) + if err != nil { + return err + } + for i, line := range strings.Split(string(data), "\n") { + if repoRkeyPattern.MatchString(line) { + t.Errorf("%s:%d uses .Repo.Rkey or .RepoInfo.Rkey in URL position. Use .Slug to prefer Name over TID-Rkey.\n %s", + path, i+1, strings.TrimSpace(line)) + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +var bareDidAllowlist = map[string]bool{ + "templates/strings/string.html": true, + "templates/strings/fragments/form.html": true, + "templates/spindles/dashboard.html": true, +} + +var didCloseAsUrlSegment = regexp.MustCompile(`\.(?:Did|OwnerDid)\s*\}\}\s*/`) +var printfWithUrlFormat = regexp.MustCompile(`printf\s+"[^"]*/[^"]*%s`) +var didArgRef = regexp.MustCompile(`\b[\$\.]\w+(?:\.\w+)*\.(?:Did|OwnerDid)\b`) + +func TestNoBareDidInTemplateRepoUrls(t *testing.T) { + err := fs.WalkDir(Files, "templates", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || !strings.HasSuffix(path, ".html") { + return nil + } + if bareDidAllowlist[path] { + return nil + } + data, err := fs.ReadFile(Files, path) + if err != nil { + return err + } + for i, line := range strings.Split(string(data), "\n") { + for _, idx := range didCloseAsUrlSegment.FindAllStringIndex(line, -1) { + openIdx := strings.LastIndex(line[:idx[0]], "{{") + if openIdx == -1 { + continue + } + action := line[openIdx:idx[1]] + if !strings.Contains(action, "resolve") { + t.Errorf("%s:%d renders raw DID as URL path segment. Wrap in `resolve` so the handle appears.\n %s", + path, i+1, strings.TrimSpace(line)) + break + } + } + if printfWithUrlFormat.MatchString(line) && didArgRef.MatchString(line) && !strings.Contains(line, "resolve ") { + t.Errorf("%s:%d builds a URL path via printf with a raw DID arg. Wrap the DID in `resolve` so handle appears.\n %s", + path, i+1, strings.TrimSpace(line)) + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} diff --git a/appview/pages/repoinfo/repoinfo.go b/appview/pages/repoinfo/repoinfo.go index 89a93c18..bbd870aa 100644 --- a/appview/pages/repoinfo/repoinfo.go +++ b/appview/pages/repoinfo/repoinfo.go @@ -19,8 +19,15 @@ func (r RepoInfo) owner() string { } } +func (r RepoInfo) Slug() string { + if r.Name != "" { + return r.Name + } + return r.Rkey +} + func (r RepoInfo) FullName() string { - return path.Join(r.owner(), r.Rkey) + return path.Join(r.owner(), r.Slug()) } func (r RepoInfo) RepoIdentifier() string { @@ -39,7 +46,7 @@ func (r RepoInfo) ownerWithoutAt() string { } func (r RepoInfo) FullNameWithoutAt() string { - return path.Join(r.ownerWithoutAt(), r.Rkey) + return path.Join(r.ownerWithoutAt(), r.Slug()) } func (r RepoInfo) GetTabs() [][]string { diff --git a/appview/pages/repoinfo/repoinfo_test.go b/appview/pages/repoinfo/repoinfo_test.go new file mode 100644 index 00000000..28b6eaf7 --- /dev/null +++ b/appview/pages/repoinfo/repoinfo_test.go @@ -0,0 +1,46 @@ +package repoinfo + +import "testing" + +func TestSlug(t *testing.T) { + cases := []struct { + name string + info RepoInfo + want string + }{ + {"name preferred over rkey", RepoInfo{Name: "barnacle", Rkey: "3kabc"}, "barnacle"}, + {"name equals rkey", RepoInfo{Name: "clam", Rkey: "clam"}, "clam"}, + {"name empty falls to rkey", RepoInfo{Rkey: "limpet"}, "limpet"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := c.info.Slug(); got != c.want { + t.Errorf("Slug() = %q, want %q", got, c.want) + } + }) + } +} + +func TestFullName_PrefersNameOverRkey(t *testing.T) { + info := RepoInfo{OwnerHandle: "boltless.dev", Name: "uni", Rkey: "3kabcxyz"} + if got, want := info.FullName(), "boltless.dev/uni"; got != want { + t.Errorf("FullName() = %q, want %q", got, want) + } + if got, want := info.FullNameWithoutAt(), "boltless.dev/uni"; got != want { + t.Errorf("FullNameWithoutAt() = %q, want %q", got, want) + } +} + +func TestFullName_FallsBackToRkey(t *testing.T) { + info := RepoInfo{OwnerHandle: "akshay.dev", Rkey: "3kabcxyz"} + if got, want := info.FullName(), "akshay.dev/3kabcxyz"; got != want { + t.Errorf("FullName() = %q, want %q", got, want) + } +} + +func TestFullNameWithoutAt_FlattensDid(t *testing.T) { + info := RepoInfo{OwnerDid: "did:plc:boltless", Name: "nautilus", Rkey: "nautilus"} + if got, want := info.FullNameWithoutAt(), "did-plc-boltless/nautilus"; got != want { + t.Errorf("FullNameWithoutAt() = %q, want %q", got, want) + } +} diff --git a/appview/pages/templates/goodfirstissues/index.html b/appview/pages/templates/goodfirstissues/index.html index d0ca6af0..0adc8263 100644 --- a/appview/pages/templates/goodfirstissues/index.html +++ b/appview/pages/templates/goodfirstissues/index.html @@ -46,7 +46,7 @@ {{ i "book-marked" "w-4 h-4 mr-1.5 shrink-0" }} {{ end }} {{ $repoOwner := resolve .Repo.Did }} - {{ $repoOwner }}/{{ .Repo.Name }} + {{ $repoOwner }}/{{ .Repo.Name }} @@ -90,7 +90,7 @@ {{ if gt (len .Issues) 0 }}