Monorepo for Tangled tangled.org

appview: remove `@` from URLs and interface

old URLs that refer to users with the `@` are redirected to the version
without `@`. the leading motivation for this change is that valid
atproto handles do not contain the prefix. it is purely stylistic.

Signed-off-by: oppiliappan <me@oppi.li>

Changed files
+50 -48
appview
middleware
pages
repoinfo
templates
layouts
repo
fragments
state
+2 -2
appview/middleware/middleware.go
··· 180 180 return func(next http.Handler) http.Handler { 181 181 return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 182 182 didOrHandle := chi.URLParam(req, "user") 183 + didOrHandle = strings.TrimPrefix(didOrHandle, "@") 184 + 183 185 if slices.Contains(excluded, didOrHandle) { 184 186 next.ServeHTTP(w, req) 185 187 return 186 188 } 187 - 188 - didOrHandle = strings.TrimPrefix(didOrHandle, "@") 189 189 190 190 id, err := mw.idResolver.ResolveIdent(req.Context(), didOrHandle) 191 191 if err != nil {
+4 -3
appview/pages/funcmap.go
··· 17 17 "strings" 18 18 "time" 19 19 20 + "github.com/bluesky-social/indigo/atproto/syntax" 20 21 "github.com/dustin/go-humanize" 21 22 "github.com/go-enry/go-enry/v2" 22 23 "tangled.org/core/appview/filetree" ··· 63 64 return "handle.invalid" 64 65 } 65 66 66 - return "@" + identity.Handle.String() 67 + return identity.Handle.String() 67 68 }, 68 69 "truncateAt30": func(s string) string { 69 70 if len(s) <= 30 { ··· 123 124 return b 124 125 }, 125 126 "didOrHandle": func(did, handle string) string { 126 - if handle != "" { 127 - return fmt.Sprintf("@%s", handle) 127 + if handle != "" && handle != syntax.HandleInvalid.String() { 128 + return handle 128 129 } else { 129 130 return did 130 131 }
+5 -7
appview/pages/repoinfo/repoinfo.go
··· 1 1 package repoinfo 2 2 3 3 import ( 4 - "fmt" 5 4 "path" 6 5 "slices" 7 - "strings" 8 6 9 7 "github.com/bluesky-social/indigo/atproto/syntax" 10 8 "tangled.org/core/appview/models" 11 9 "tangled.org/core/appview/state/userutil" 12 10 ) 13 11 14 - func (r RepoInfo) OwnerWithAt() string { 12 + func (r RepoInfo) Owner() string { 15 13 if r.OwnerHandle != "" { 16 - return fmt.Sprintf("@%s", r.OwnerHandle) 14 + return r.OwnerHandle 17 15 } else { 18 16 return r.OwnerDid 19 17 } 20 18 } 21 19 22 20 func (r RepoInfo) FullName() string { 23 - return path.Join(r.OwnerWithAt(), r.Name) 21 + return path.Join(r.Owner(), r.Name) 24 22 } 25 23 26 24 func (r RepoInfo) OwnerWithoutAt() string { 27 - if after, ok := strings.CutPrefix(r.OwnerWithAt(), "@"); ok { 28 - return after 25 + if r.OwnerHandle != "" { 26 + return r.OwnerHandle 29 27 } else { 30 28 return userutil.FlattenDid(r.OwnerDid) 31 29 }
+2 -2
appview/pages/templates/layouts/repobase.html
··· 13 13 </p> 14 14 {{ end }} 15 15 <div class="text-lg flex items-center justify-between"> 16 - <div> 17 - <a href="/{{ .RepoInfo.OwnerWithAt }}">{{ .RepoInfo.OwnerWithAt }}</a> 16 + <div class="flex items-center gap-2 flex-wrap"> 17 + {{ template "user/fragments/picHandleLink" .RepoInfo.OwnerDid }} 18 18 <span class="select-none">/</span> 19 19 <a href="/{{ .RepoInfo.FullName }}" class="font-bold">{{ .RepoInfo.Name }}</a> 20 20 </div>
+2 -2
appview/pages/templates/repo/fragments/cloneDropdown.html
··· 29 29 <code 30 30 class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto" 31 31 onclick="window.getSelection().selectAllChildren(this)" 32 - data-url="https://tangled.org/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}" 33 - >https://tangled.org/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code> 32 + data-url="https://tangled.org/{{ resolve .RepoInfo.OwnerDid }}/{{ .RepoInfo.Name }}" 33 + >https://tangled.org/{{ resolve .RepoInfo.OwnerDid }}/{{ .RepoInfo.Name }}</code> 34 34 <button 35 35 onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))" 36 36 class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600"
+26 -24
appview/state/router.go
··· 42 42 43 43 router.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) { 44 44 pat := chi.URLParam(r, "*") 45 - if strings.HasPrefix(pat, "did:") || strings.HasPrefix(pat, "@") { 46 - userRouter.ServeHTTP(w, r) 47 - } else { 48 - // Check if the first path element is a valid handle without '@' or a flattened DID 49 - pathParts := strings.SplitN(pat, "/", 2) 50 - if len(pathParts) > 0 { 51 - if userutil.IsHandleNoAt(pathParts[0]) { 52 - // Redirect to the same path but with '@' prefixed to the handle 53 - redirectPath := "@" + pat 54 - http.Redirect(w, r, "/"+redirectPath, http.StatusFound) 55 - return 56 - } else if userutil.IsFlattenedDid(pathParts[0]) { 57 - // Redirect to the unflattened DID version 58 - unflattenedDid := userutil.UnflattenDid(pathParts[0]) 59 - var redirectPath string 60 - if len(pathParts) > 1 { 61 - redirectPath = unflattenedDid + "/" + pathParts[1] 62 - } else { 63 - redirectPath = unflattenedDid 64 - } 65 - http.Redirect(w, r, "/"+redirectPath, http.StatusFound) 66 - return 67 - } 45 + pathParts := strings.SplitN(pat, "/", 2) 46 + 47 + if len(pathParts) > 0 { 48 + firstPart := pathParts[0] 49 + 50 + // if using a DID or handle, just continue as per usual 51 + if userutil.IsDid(firstPart) || userutil.IsHandle(firstPart) { 52 + userRouter.ServeHTTP(w, r) 53 + return 68 54 } 69 - standardRouter.ServeHTTP(w, r) 55 + 56 + // if using a flattened DID (like you would in go modules), unflatten 57 + if userutil.IsFlattenedDid(firstPart) { 58 + unflattenedDid := userutil.UnflattenDid(firstPart) 59 + redirectPath := strings.Join(append([]string{unflattenedDid}, pathParts[1:]...), "/") 60 + http.Redirect(w, r, "/"+redirectPath, http.StatusFound) 61 + return 62 + } 63 + 64 + // if using a handle with @, rewrite to work without @ 65 + if normalized := strings.TrimPrefix(firstPart, "@"); userutil.IsHandle(normalized) { 66 + redirectPath := strings.Join(append([]string{normalized}, pathParts[1:]...), "/") 67 + http.Redirect(w, r, "/"+redirectPath, http.StatusFound) 68 + return 69 + } 70 70 } 71 + 72 + standardRouter.ServeHTTP(w, r) 71 73 }) 72 74 73 75 return router
+3 -2
appview/state/state.go
··· 386 386 387 387 pubKeys, err := db.GetPublicKeysForDid(s.db, id.DID.String()) 388 388 if err != nil { 389 - w.WriteHeader(http.StatusNotFound) 389 + s.logger.Error("failed to get public keys", "err", err) 390 + http.Error(w, "failed to get public keys", http.StatusInternalServerError) 390 391 return 391 392 } 392 393 393 394 if len(pubKeys) == 0 { 394 - w.WriteHeader(http.StatusNotFound) 395 + w.WriteHeader(http.StatusNoContent) 395 396 return 396 397 } 397 398
+6 -6
appview/state/userutil/userutil.go
··· 10 10 didRegex = regexp.MustCompile(`^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$`) 11 11 ) 12 12 13 - func IsHandleNoAt(s string) bool { 13 + func IsHandle(s string) bool { 14 14 // ref: https://atproto.com/specs/handle 15 15 return handleRegex.MatchString(s) 16 + } 17 + 18 + // IsDid checks if the given string is a standard DID. 19 + func IsDid(s string) bool { 20 + return didRegex.MatchString(s) 16 21 } 17 22 18 23 func UnflattenDid(s string) string { ··· 45 50 return strings.Replace(s, ":", "-", 2) 46 51 } 47 52 return s 48 - } 49 - 50 - // IsDid checks if the given string is a standard DID. 51 - func IsDid(s string) bool { 52 - return didRegex.MatchString(s) 53 53 } 54 54 55 55 var subdomainRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{2,61}[a-z0-9])?$`)