diff --git a/appview/pages/pages.go b/appview/pages/pages.go --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -22,6 +22,7 @@ "github.com/microcosm-cc/bluemonday" "github.com/sotangled/tangled/appview/auth" "github.com/sotangled/tangled/appview/db" + "github.com/sotangled/tangled/appview/state/userutil" "github.com/sotangled/tangled/types" ) @@ -252,6 +253,18 @@ return path.Join(r.OwnerWithAt(), r.Name) } +func (r RepoInfo) OwnerWithoutAt() string { + if strings.HasPrefix(r.OwnerWithAt(), "@") { + return strings.TrimPrefix(r.OwnerWithAt(), "@") + } else { + return userutil.FlattenDid(r.OwnerDid) + } +} + +func (r RepoInfo) FullNameWithoutAt() string { + return path.Join(r.OwnerWithoutAt(), r.Name) +} + func (r RepoInfo) GetTabs() [][]string { tabs := [][]string{ {"overview", "/"}, @@ -328,7 +341,7 @@ LoggedInUser *auth.User RepoInfo RepoInfo types.RepoLogResponse - Active string + Active string EmailToDidOrHandle map[string]string } diff --git a/appview/state/router.go b/appview/state/router.go --- a/appview/state/router.go +++ b/appview/state/router.go @@ -2,10 +2,10 @@ import ( "net/http" - "regexp" "strings" "github.com/go-chi/chi/v5" + "github.com/sotangled/tangled/appview/state/userutil" ) func (s *State) Router() http.Handler { @@ -19,14 +19,14 @@ // Check if the first path element is a valid handle without '@' or a flattened DID pathParts := strings.SplitN(pat, "/", 2) if len(pathParts) > 0 { - if isHandleNoAt(pathParts[0]) { + if userutil.IsHandleNoAt(pathParts[0]) { // Redirect to the same path but with '@' prefixed to the handle redirectPath := "@" + pat http.Redirect(w, r, "/"+redirectPath, http.StatusFound) return - } else if isFlattenedDid(pathParts[0]) { + } else if userutil.IsFlattenedDid(pathParts[0]) { // Redirect to the unflattened DID version - unflattenedDid := unflattenDid(pathParts[0]) + unflattenedDid := userutil.UnflattenDid(pathParts[0]) var redirectPath string if len(pathParts) > 1 { redirectPath = unflattenedDid + "/" + pathParts[1] @@ -42,48 +42,6 @@ }) return router -} - -func isHandleNoAt(s string) bool { - // ref: https://atproto.com/specs/handle - re := regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$`) - return re.MatchString(s) -} - -func unflattenDid(s string) string { - if !isFlattenedDid(s) { - return s - } - - parts := strings.SplitN(s[4:], "-", 2) // Skip "did-" prefix and split on first "-" - if len(parts) != 2 { - return s - } - - return "did:" + parts[0] + ":" + parts[1] -} - -// isFlattenedDid checks if the given string is a flattened DID. -// A flattened DID is a DID with the :s swapped to -s to satisfy certain -// application requirements, such as Go module naming conventions. -func isFlattenedDid(s string) bool { - // Check if the string starts with "did-" - if !strings.HasPrefix(s, "did-") { - return false - } - - // Split the string to extract method and identifier - parts := strings.SplitN(s[4:], "-", 2) // Skip "did-" prefix and split on first "-" - if len(parts) != 2 { - return false - } - - // Reconstruct as a standard DID format - // Example: "did-plc-xyz-abc" becomes "did:plc:xyz-abc" - reconstructed := "did:" + parts[0] + ":" + parts[1] - re := regexp.MustCompile(`^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$`) - - return re.MatchString(reconstructed) } func (s *State) UserRouter() http.Handler { diff --git a/appview/state/router_test.go b/appview/state/router_test.go deleted file mode 100644 --- a/appview/state/router_test.go +++ /dev/null @@ -1,114 +0,0 @@ -package state - -import "testing" - -func TestUnflattenDid(t *testing.T) { - unflattenedMap := map[string]string{ - "did-plc-abcdefghijklmnopqrstuvwxyz": "did:plc:abcdefghijklmnopqrstuvwxyz", - "did-plc-1234567890": "did:plc:1234567890", - "did-key-z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", - "did-plc-abcdefghi-jklmnopqr-stuvwxyz": "did:plc:abcdefghi-jklmnopqr-stuvwxyz", - "plc-abcdefghijklmnopqrstuvwxyz": "plc-abcdefghijklmnopqrstuvwxyz", - "didplc-abcdefghijklmnopqrstuvwxyz": "didplc-abcdefghijklmnopqrstuvwxyz", - "": "", - "did-": "did-", - "did:plc:abcdefghijklmnopqrstuvwxyz": "did:plc:abcdefghijklmnopqrstuvwxyz", - "did-invalid$format:something": "did-invalid$format:something", - } - - tests := []struct { - name string - input string - expected string - }{} - - for _, tc := range isFlattenedDidTests { - tests = append(tests, struct { - name string - input string - expected string - }{ - name: tc.name, - input: tc.input, - expected: unflattenedMap[tc.input], - }) - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - result := unflattenDid(tc.input) - if result != tc.expected { - t.Errorf("unflattenDid(%q) = %q, want %q", tc.input, result, tc.expected) - } - }) - } -} - -var isFlattenedDidTests = []struct { - name string - input string - expected bool -}{ - { - name: "valid flattened DID", - input: "did-plc-abcdefghijklmnopqrstuvwxyz", - expected: true, - }, - { - name: "valid flattened DID with numbers", - input: "did-plc-1234567890", - expected: true, - }, - { - name: "valid flattened DID with special characters", - input: "did-key-z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", - expected: true, - }, - { - name: "valid flattened DID with dashes", - input: "did-plc-abcdefghi-jklmnopqr-stuvwxyz", - expected: true, - }, - - { - name: "doesn't start with did-", - input: "plc-abcdefghijklmnopqrstuvwxyz", - expected: false, - }, - { - name: "no hyphen after did", - input: "didplc-abcdefghijklmnopqrstuvwxyz", - expected: false, - }, - { - name: "empty string", - input: "", - expected: false, - }, - { - name: "only did-", - input: "did-", - expected: false, - }, - { - name: "standard DID format, not flattened", - input: "did:plc:abcdefghijklmnopqrstuvwxyz", - expected: false, - }, - { - name: "invalid reconstructed DID format", - input: "did-invalid$format:something", - expected: false, - }, -} - -func TestIsFlattenedDid(t *testing.T) { - for _, tc := range isFlattenedDidTests { - t.Run(tc.name, func(t *testing.T) { - result := isFlattenedDid(tc.input) - if result != tc.expected { - t.Errorf("isFlattenedDid(%q) = %v, want %v", tc.input, result, tc.expected) - } - }) - } -} diff --git a/appview/state/userutil/userutil.go b/appview/state/userutil/userutil.go new file mode 100644 --- /dev/null +++ b/appview/state/userutil/userutil.go @@ -0,0 +1,62 @@ +package userutil + +import ( + "regexp" + "strings" +) + +func IsHandleNoAt(s string) bool { + // ref: https://atproto.com/specs/handle + re := regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$`) + return re.MatchString(s) +} + +func UnflattenDid(s string) string { + if !IsFlattenedDid(s) { + return s + } + + parts := strings.SplitN(s[4:], "-", 2) // Skip "did-" prefix and split on first "-" + if len(parts) != 2 { + return s + } + + return "did:" + parts[0] + ":" + parts[1] +} + +// IsFlattenedDid checks if the given string is a flattened DID. +func IsFlattenedDid(s string) bool { + // Check if the string starts with "did-" + if !strings.HasPrefix(s, "did-") { + return false + } + + // Split the string to extract method and identifier + parts := strings.SplitN(s[4:], "-", 2) // Skip "did-" prefix and split on first "-" + if len(parts) != 2 { + return false + } + + // Reconstruct as a standard DID format + // Example: "did-plc-xyz-abc" becomes "did:plc:xyz-abc" + reconstructed := "did:" + parts[0] + ":" + parts[1] + re := regexp.MustCompile(`^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$`) + + return re.MatchString(reconstructed) +} + +// FlattenDid converts a DID to a flattened format. +// A flattened DID is a DID with the :s swapped to -s to satisfy certain +// application requirements, such as Go module naming conventions. +func FlattenDid(s string) string { + if !IsFlattenedDid(s) { + return s + } + + parts := strings.SplitN(s[4:], ":", 2) // Skip "did:" prefix and split on first ":" + if len(parts) != 2 { + return s + } + + return "did-" + parts[0] + "-" + parts[1] +} diff --git a/appview/state/userutil/userutil_test.go b/appview/state/userutil/userutil_test.go new file mode 100644 --- /dev/null +++ b/appview/state/userutil/userutil_test.go @@ -0,0 +1,114 @@ +package userutil + +import "testing" + +func TestUnflattenDid(t *testing.T) { + unflattenedMap := map[string]string{ + "did-plc-abcdefghijklmnopqrstuvwxyz": "did:plc:abcdefghijklmnopqrstuvwxyz", + "did-plc-1234567890": "did:plc:1234567890", + "did-key-z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + "did-plc-abcdefghi-jklmnopqr-stuvwxyz": "did:plc:abcdefghi-jklmnopqr-stuvwxyz", + "plc-abcdefghijklmnopqrstuvwxyz": "plc-abcdefghijklmnopqrstuvwxyz", + "didplc-abcdefghijklmnopqrstuvwxyz": "didplc-abcdefghijklmnopqrstuvwxyz", + "": "", + "did-": "did-", + "did:plc:abcdefghijklmnopqrstuvwxyz": "did:plc:abcdefghijklmnopqrstuvwxyz", + "did-invalid$format:something": "did-invalid$format:something", + } + + tests := []struct { + name string + input string + expected string + }{} + + for _, tc := range isFlattenedDidTests { + tests = append(tests, struct { + name string + input string + expected string + }{ + name: tc.name, + input: tc.input, + expected: unflattenedMap[tc.input], + }) + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := UnflattenDid(tc.input) + if result != tc.expected { + t.Errorf("unflattenDid(%q) = %q, want %q", tc.input, result, tc.expected) + } + }) + } +} + +var isFlattenedDidTests = []struct { + name string + input string + expected bool +}{ + { + name: "valid flattened DID", + input: "did-plc-abcdefghijklmnopqrstuvwxyz", + expected: true, + }, + { + name: "valid flattened DID with numbers", + input: "did-plc-1234567890", + expected: true, + }, + { + name: "valid flattened DID with special characters", + input: "did-key-z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + expected: true, + }, + { + name: "valid flattened DID with dashes", + input: "did-plc-abcdefghi-jklmnopqr-stuvwxyz", + expected: true, + }, + + { + name: "doesn't start with did-", + input: "plc-abcdefghijklmnopqrstuvwxyz", + expected: false, + }, + { + name: "no hyphen after did", + input: "didplc-abcdefghijklmnopqrstuvwxyz", + expected: false, + }, + { + name: "empty string", + input: "", + expected: false, + }, + { + name: "only did-", + input: "did-", + expected: false, + }, + { + name: "standard DID format, not flattened", + input: "did:plc:abcdefghijklmnopqrstuvwxyz", + expected: false, + }, + { + name: "invalid reconstructed DID format", + input: "did-invalid$format:something", + expected: false, + }, +} + +func TestIsFlattenedDid(t *testing.T) { + for _, tc := range isFlattenedDidTests { + t.Run(tc.name, func(t *testing.T) { + result := IsFlattenedDid(tc.input) + if result != tc.expected { + t.Errorf("isFlattenedDid(%q) = %v, want %v", tc.input, result, tc.expected) + } + }) + } +} diff --git a/appview/pages/templates/layouts/base.html b/appview/pages/templates/layouts/base.html --- a/appview/pages/templates/layouts/base.html +++ b/appview/pages/templates/layouts/base.html @@ -9,7 +9,9 @@ /> +