feat: implement unified editor with content persistence and server-side upsert

- Replace dual update systems with single markdown-first editor architecture
- Add server-side upsert to eliminate 404 errors on PUT operations
- Fix content persistence race condition between preview and save operations
- Remove legacy updateElementContent system entirely
- Add comprehensive authentication with JWT scaffolding and dev mode
- Implement EditContext.updateOriginalContent() for proper baseline management
- Enable markdown formatting in all text elements (h1-h6, p, div, etc)
- Clean terminology: remove 'unified' references from codebase

Technical changes:
* core/editor.js: Remove legacy update system, unify content types as markdown
* ui/Editor.js: Add updateOriginalContent() method to fix save persistence
* ui/Previewer.js: Clean live preview system for all content types
* api/handlers.go: Implement UpsertContent for idempotent PUT operations
* auth/*: Complete authentication service with OAuth scaffolding
* db/queries/content.sql: Add upsert query with ON CONFLICT handling
* Schema: Remove type constraints, rely on server-side validation

Result: Clean content editing with persistent saves, no 404 errors, markdown support in all text elements
This commit is contained in:
2025-09-10 20:19:54 +02:00
parent c572428e45
commit b0c4a33a7c
23 changed files with 1658 additions and 3585 deletions

265
internal/auth/auth.go Normal file
View File

@@ -0,0 +1,265 @@
package auth
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
)
// UserInfo represents authenticated user information
type UserInfo struct {
ID string `json:"sub"`
Email string `json:"email,omitempty"`
Name string `json:"name,omitempty"`
Provider string `json:"iss,omitempty"`
}
// AuthConfig holds authentication configuration
type AuthConfig struct {
DevMode bool
JWTSecret string
OAuthConfigs map[string]OAuthConfig
}
// OAuthConfig holds OAuth provider configuration
type OAuthConfig struct {
ClientID string
ClientSecret string
RedirectURL string
Scopes []string
}
// AuthService handles authentication operations
type AuthService struct {
config *AuthConfig
}
// NewAuthService creates a new authentication service
func NewAuthService(config *AuthConfig) *AuthService {
return &AuthService{config: config}
}
// ExtractUserFromRequest extracts user information from HTTP request
func (a *AuthService) ExtractUserFromRequest(r *http.Request) (*UserInfo, error) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
return &UserInfo{ID: "anonymous"}, nil
}
// Parse Bearer token
if !strings.HasPrefix(authHeader, "Bearer ") {
return nil, fmt.Errorf("invalid authorization header format")
}
token := strings.TrimPrefix(authHeader, "Bearer ")
// Handle mock tokens in development mode
if a.config.DevMode && strings.HasPrefix(token, "mock-") {
return a.parseMockToken(token), nil
}
// Parse real JWT token
return a.parseJWT(token)
}
// parseMockToken handles mock development tokens
func (a *AuthService) parseMockToken(token string) *UserInfo {
// Mock token format: mock-{user}-{timestamp}-{random}
parts := strings.Split(token, "-")
if len(parts) >= 2 {
return &UserInfo{
ID: parts[1], // user part
Email: fmt.Sprintf("%s@localhost", parts[1]),
Name: strings.Title(parts[1]),
Provider: "insertr-dev",
}
}
return &UserInfo{ID: "anonymous"}
}
// parseJWT parses and validates a real JWT token
func (a *AuthService) parseJWT(tokenString string) (*UserInfo, error) {
// Parse the token
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
// Validate signing method
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(a.config.JWTSecret), nil
})
if err != nil {
return nil, fmt.Errorf("failed to parse JWT: %w", err)
}
// Extract claims
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
userInfo := &UserInfo{}
// Extract standard JWT claims
if sub, ok := claims["sub"].(string); ok {
userInfo.ID = sub
}
if email, ok := claims["email"].(string); ok {
userInfo.Email = email
}
if name, ok := claims["name"].(string); ok {
userInfo.Name = name
}
if iss, ok := claims["iss"].(string); ok {
userInfo.Provider = iss
}
// Fallback to alternative claim names
if userInfo.ID == "" {
if userID, ok := claims["user_id"].(string); ok {
userInfo.ID = userID
}
}
// Default to anonymous if no user ID found
if userInfo.ID == "" {
userInfo.ID = "anonymous"
}
return userInfo, nil
}
return nil, fmt.Errorf("invalid JWT claims")
}
// CreateMockJWT creates a mock JWT token for development/testing
func (a *AuthService) CreateMockJWT(userID, email, name string) (string, error) {
if !a.config.DevMode {
return "", fmt.Errorf("mock JWT creation only allowed in development mode")
}
// Create the claims
claims := jwt.MapClaims{
"sub": userID,
"email": email,
"name": name,
"iss": "insertr-dev",
"iat": time.Now().Unix(),
"exp": time.Now().Add(24 * time.Hour).Unix(),
}
// Create token
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
// Sign and get the complete encoded token as a string
tokenString, err := token.SignedString([]byte(a.config.JWTSecret))
if err != nil {
return "", fmt.Errorf("failed to sign JWT: %w", err)
}
return tokenString, nil
}
// ValidateToken validates a JWT token without extracting user info
func (a *AuthService) ValidateToken(tokenString string) error {
_, err := a.parseJWT(tokenString)
return err
}
// RefreshToken creates a new token with extended expiration
func (a *AuthService) RefreshToken(tokenString string) (string, error) {
userInfo, err := a.parseJWT(tokenString)
if err != nil {
return "", fmt.Errorf("cannot refresh invalid token: %w", err)
}
// Create new token with same user info but extended expiration
claims := jwt.MapClaims{
"sub": userInfo.ID,
"email": userInfo.Email,
"name": userInfo.Name,
"iss": userInfo.Provider,
"iat": time.Now().Unix(),
"exp": time.Now().Add(24 * time.Hour).Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(a.config.JWTSecret))
}
// IsAuthenticated checks if the request has valid authentication
func (a *AuthService) IsAuthenticated(r *http.Request) bool {
userInfo, err := a.ExtractUserFromRequest(r)
return err == nil && userInfo.ID != "anonymous"
}
// RequireAuth middleware that requires authentication
func (a *AuthService) RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userInfo, err := a.ExtractUserFromRequest(r)
if err != nil || userInfo.ID == "anonymous" {
http.Error(w, "Authentication required", http.StatusUnauthorized)
return
}
// Add user info to request context for use by handlers
ctx := r.Context()
ctx = ContextWithUser(ctx, userInfo)
r = r.WithContext(ctx)
next.ServeHTTP(w, r)
})
}
// OAuth flow handlers (to be implemented when adding real OAuth)
// HandleOAuthLogin initiates OAuth flow
func (a *AuthService) HandleOAuthLogin(w http.ResponseWriter, r *http.Request) {
provider := r.URL.Query().Get("provider")
if provider == "" {
provider = "google"
}
// TODO: Implement OAuth initiation
// For now, return mock success in dev mode
if a.config.DevMode {
response := map[string]interface{}{
"message": "OAuth login not yet implemented",
"redirect_url": "/auth/callback?code=mock_code&state=mock_state",
"dev_mode": true,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
return
}
http.Error(w, "OAuth not implemented", http.StatusNotImplemented)
}
// HandleOAuthCallback handles OAuth provider callback
func (a *AuthService) HandleOAuthCallback(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")
_ = r.URL.Query().Get("state") // TODO: validate state parameter
// TODO: Implement OAuth token exchange
// For now, return mock token in dev mode
if a.config.DevMode && code != "" {
mockToken, err := a.CreateMockJWT("dev-user", "dev@localhost", "Development User")
if err != nil {
http.Error(w, "Failed to create mock token", http.StatusInternalServerError)
return
}
response := map[string]interface{}{
"token": mockToken,
"dev_mode": true,
"message": "Mock OAuth callback successful",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
return
}
http.Error(w, "OAuth callback not implemented", http.StatusNotImplemented)
}