feat(docs): standalone docs site chrome + category/subcategory navigation
Docs pages no longer embed the app Header (whose hx-post login form silently degraded to a native GET, leaking credentials into the URL — this page never loaded htmx). New docs-owned topbar: search, theme picker, Open App; anonymous visitors get a plain /login link. Optional-auth middleware (hand-rolled — echojwt's ErrorHandler return ends the chain, producing empty 200s) populates the user for signed-in visitors. Navigation rebuilt from the docs directory tree: 6 sections, subcategory groups, server-rendered expansion and active states. Search results are a templ partial (/docs/search/partial); code highlighting is server-side chroma (previously dead hljs CSS); prose is theme-aware via typography DEFAULT vars (prose-invert painted unreadable text under light themes). /docs renders the index directly; standalone 404 without the tailwind CDN. Tests: navigation routing/expansion (internal/docs), render pins asserting no login form/password field/app sidebar ever return on docs pages. lint-rendering: exempt *.test.ts fixtures (jsdom DOM building); docs.ts baseline 5 -> 1.
This commit is contained in:
1 parent
9d0ae2bcc4
commit
84cd6328f4
19 files changed
+2030
-1097
No files matched your search
+11
-2
@@ -6,8 +6,17 @@ import (
|
||||
|
||||
func registerDocumentationRoutes(cfg *Config) {
|
||||
docsHandler := docs.NewHTTPHandler("docs")
|
||||
cfg.Echo.GET("/docs", docsHandler.DocsHome)
|
||||
cfg.Echo.GET("/docs/*", docsHandler.ShowDocumentation)
|
||||
|
||||
// Docs render for anonymous AND signed-in visitors (it's the pre-login
|
||||
// surface for setup/troubleshooting), so the HTML routes use the optional
|
||||
// auth middleware: handlers get a populated user when a valid token is
|
||||
// present, nil otherwise. The JSON/partial endpoints are public.
|
||||
docsAuth := createOptionalAuthMiddleware(cfg)
|
||||
cfg.Echo.GET("/docs", docsHandler.DocsHome, docsAuth)
|
||||
cfg.Echo.GET("/docs/*", docsHandler.ShowDocumentation, docsAuth)
|
||||
|
||||
// Server-rendered search results partial (swapped in by web/src/docs.ts).
|
||||
cfg.Echo.GET("/docs/search/partial", docsHandler.SearchPartial)
|
||||
cfg.Echo.GET("/docs/api/search", docsHandler.Search)
|
||||
cfg.Echo.GET("/docs/search-index.json", docsHandler.ServeSearchIndex)
|
||||
}
|
||||
@@ -706,9 +706,9 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
books, libRes, isSmart, errCode := cfg.loadCollectionBooks(c, user, collection, collectionID)
|
||||
if errCode != "" {
|
||||
msg := map[string]string{
|
||||
"books_load_error": "Error loading books",
|
||||
"books_load_error": "Error loading books",
|
||||
"invalid_library_id": "Invalid library ID",
|
||||
"invalid_id": "Invalid collection ID",
|
||||
"invalid_id": "Invalid collection ID",
|
||||
}[errCode]
|
||||
return renderErrorPage(c, msg, errCode)
|
||||
}
|
||||
|
||||
@@ -95,6 +95,66 @@ func (cfg *Config) getBaseURL(ctx context.Context) string {
|
||||
return baseURL
|
||||
}
|
||||
|
||||
// createOptionalAuthMiddleware parses the JWT when one is present but never
|
||||
// rejects the request: pages rendered for both anonymous and signed-in
|
||||
// visitors (the documentation) use it so handlers see a populated "user"
|
||||
// when a valid token exists and nil otherwise.
|
||||
//
|
||||
// This is deliberately NOT echojwt: its ErrorHandler's return value ends the
|
||||
// chain, so "return nil" for a missing token would abort the request with an
|
||||
// empty 200 instead of continuing (that shipped once — every /docs page came
|
||||
// back blank). A ghost-session token authenticates as nobody, same rule as
|
||||
// the required middleware minus the redirect.
|
||||
func createOptionalAuthMiddleware(cfg *Config) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c *echo.Context) error {
|
||||
tokenString := ""
|
||||
if cookie, err := c.Cookie("token"); err == nil && cookie.Value != "" {
|
||||
tokenString = cookie.Value
|
||||
} else if auth := c.Request().Header.Get("Authorization"); strings.HasPrefix(auth, "Bearer ") {
|
||||
tokenString = strings.TrimPrefix(auth, "Bearer ")
|
||||
}
|
||||
if tokenString == "" {
|
||||
return next(c) // anonymous
|
||||
}
|
||||
|
||||
token, err := jwt.Parse(tokenString, func(t *jwt.Token) (any, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, jwt.ErrSignatureInvalid
|
||||
}
|
||||
return []byte(cfg.Cfg.JWTSecret), nil
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
return next(c) // invalid/expired → anonymous
|
||||
}
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
userIDStr, _ := claims["user_id"].(string)
|
||||
userUUID, err := uuid.Parse(userIDStr)
|
||||
if err != nil {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
liveUser, exists := fetchLiveUser(cfg, userUUID)
|
||||
if !exists {
|
||||
return next(c) // ghost session → anonymous
|
||||
}
|
||||
|
||||
c.Set("user", database.Users{
|
||||
ID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
Email: claims["user_email"].(string),
|
||||
Username: claims["user_username"].(string),
|
||||
Role: claims["user_role"].(string),
|
||||
Timezone: liveUser.Timezone,
|
||||
})
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// createJWTMiddleware creates a JWT middleware with proper user context setup
|
||||
func createJWTMiddleware(cfg *Config) echo.MiddlewareFunc {
|
||||
return echojwt.WithConfig(echojwt.Config{
|
||||
|
||||
Reference in new issue
Block a user