diff --git a/internal/atproto/utils.go b/internal/atproto/utils.go new file mode 100644 index 0000000..57bd040 --- /dev/null +++ b/internal/atproto/utils.go @@ -0,0 +1,20 @@ +package atproto + +import ( + "regexp" +) + +var ( + // ref: https://atproto.com/specs/handle + handleRegex = 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])?$`) + // ref: https://atproto.com/specs/did + didRegex = regexp.MustCompile(`^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$`) +) + +func IsHandle(s string) bool { + return handleRegex.MatchString(s) +} + +func IsDid(s string) bool { + return didRegex.MatchString(s) +} diff --git a/internal/server/middleware/middleware.go b/internal/server/middleware/middleware.go new file mode 100644 index 0000000..c596c2a --- /dev/null +++ b/internal/server/middleware/middleware.go @@ -0,0 +1,60 @@ +package middleware + +import ( + "context" + "log/slog" + "net/http" + "slices" + "strings" + + "github.com/go-chi/chi/v5" + "shlf.space/internal/atproto" + "shlf.space/internal/server/oauth" + notfound "shlf.space/internal/views/not-found" +) + +type CtxKey string + +const UnreadNotificationCountCtxKey CtxKey = "unreadNotificationCount" + +type Middleware struct { + oauth *oauth.OAuth + idResolver *atproto.Resolver +} + +func New(oauth *oauth.OAuth, idResolver *atproto.Resolver) Middleware { + return Middleware{ + oauth: oauth, + idResolver: idResolver, + } +} + +type middlewareFunc func(http.Handler) http.Handler + +func (mw Middleware) ResolveIdent() middlewareFunc { + excluded := []string{"favicon.ico", "favicon.svg"} + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + didOrHandle := chi.URLParam(r, "user") + didOrHandle = strings.TrimPrefix(didOrHandle, "@") + + if slices.Contains(excluded, didOrHandle) { + next.ServeHTTP(w, r) + return + } + + id, err := mw.idResolver.ResolveIdent(r.Context(), didOrHandle) + if err != nil { + slog.Error("failed to resolve did/handle", "err", err) + w.WriteHeader(http.StatusNotFound) + notfound.NotFoundPage(notfound.NotFoundParams{}).Render(r.Context(), w) + return + } + + ctx := context.WithValue(r.Context(), "resolvedId", *id) + + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} diff --git a/internal/server/router.go b/internal/server/router.go index 9df017b..016f58d 100644 --- a/internal/server/router.go +++ b/internal/server/router.go @@ -2,12 +2,58 @@ package server import ( "net/http" + "strings" "github.com/go-chi/chi/v5" + "shlf.space/internal/atproto" + "shlf.space/internal/server/middleware" + notfound "shlf.space/internal/views/not-found" ) func (s *Server) Router() http.Handler { router := chi.NewRouter() + middleware := middleware.New( + s.oauth, + s.idResolver, + ) + + userRouter := s.UserRouter(&middleware) + standardRouter := s.StandardRouter(&middleware) + + router.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) { + pat := chi.URLParam(r, "*") + pathParts := strings.SplitN(pat, "/", 2) + + if len(pathParts) > 0 { + firstPart := pathParts[0] + + // if using a DID or handle, just continue as per usual + if atproto.IsDid(firstPart) || atproto.IsHandle(firstPart) { + userRouter.ServeHTTP(w, r) + return + } + + // if using a handle with @, rewrite to work without @ + if normalized := strings.TrimPrefix(firstPart, "@"); atproto.IsHandle(normalized) { + redirectPath := strings.Join(append([]string{normalized}, pathParts[1:]...), "/") + + redirectURL := *r.URL + redirectURL.Path = "/" + redirectPath + + http.Redirect(w, r, redirectURL.String(), http.StatusFound) + return + } + + } + + standardRouter.ServeHTTP(w, r) + }) + + return router +} + +func (s *Server) StandardRouter(middleware *middleware.Middleware) http.Handler { + router := chi.NewRouter() router.Handle("/static/*", s.HandleStatic()) @@ -21,3 +67,17 @@ func (s *Server) Router() http.Handler { return router } + +func (s *Server) UserRouter(middleware *middleware.Middleware) http.Handler { + router := chi.NewRouter() + + router.With(middleware.ResolveIdent()).Route("/{user}", func(r chi.Router) { + }) + + router.NotFound(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + notfound.NotFoundPage(notfound.NotFoundParams{}).Render(r.Context(), w) + }) + + return router +} diff --git a/internal/views/not-found/not-found.go b/internal/views/not-found/not-found.go new file mode 100644 index 0000000..b39bc5c --- /dev/null +++ b/internal/views/not-found/not-found.go @@ -0,0 +1,3 @@ +package notfound + +type NotFoundParams struct{} diff --git a/internal/views/not-found/not-found.templ b/internal/views/not-found/not-found.templ new file mode 100644 index 0000000..5bf66c1 --- /dev/null +++ b/internal/views/not-found/not-found.templ @@ -0,0 +1,9 @@ +package notfound + +import "shlf.space/internal/layouts/base" + +templ NotFoundPage(params NotFoundParams) { + @layouts.Base(layouts.BaseParams{Title: "not found"}) { +
not found...
+ } +}