diff --git a/CHANGELOG.md b/CHANGELOG.md index dae3f2f3..f26354cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,16 +9,65 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +#### Security Hardening + +- **httpOnly cookie authentication**: Migrated auth tokens from localStorage to httpOnly cookies, eliminating XSS token theft vectors +- **Fixed-window rate limiting**: Added per-endpoint rate limiting to all API routes to prevent abuse +- **Input validation hardening**: Strengthened validation across all user-facing endpoints +- **SQL injection prevention**: Added allowlist validation for ORDER BY clauses in repository layer (defense-in-depth for `question.go` and `answer.go`) +- **Insecure randomness fix**: Replaced `Math.random()` fallback with `crypto.getRandomValues()` for cryptographically secure session ID generation in `ChatPanel.vue` +- **Auth token extraction hardening**: Improved multi-source token extraction security (header, cookie) +- **OpenAI error sanitization**: Removed response body from OpenAI error messages to prevent data exposure + +#### Photo Gallery & Lifecycle Management + +- **Photo lifecycle management**: Auto-cleanup of expired photos and admin-level photo controls +- **Pic wall**: Interactive photo wall with drag, zoom, and close-window UI +- **AI photo generation**: AI-generated photos in memoir using image model integration + +#### Memoir & Echo Enhancements + +- **Memoir text editing**: Users can edit AI-generated memoir text inline +- **Image regeneration**: Regenerate memoir cover images on demand + +#### Whisper & Tasks + +- **Whisper (Conque)**: Audio-to-text conversation feature using speech recognition +- **Tasks (Star)**: Goal-setting and task-tracking system with partner support +- **Partner task UI**: Improved task interface with reject and polling support + +#### Admin Panel + +- **Frontend admin exposure**: Admin panel accessible through the Vue frontend with navigation integration + +#### Community Enhancements + +- **AI auto-reply trigger**: AI reply triggered on answers mentioning @小石光 +- **Cascade delete**: Deleting a question now cascades to its answers and comments +- **Edit/delete UI**: Added edit and delete controls for questions, answers, and comments +- **AI avatar**: Dedicated AI user avatar with simplified mention pattern +- **Source citation**: AI replies cite sources only for factual claims, not emotional support; sources renumbered sequentially + #### Vue 3 Frontend -- **Background music loop**: Added global alternating playback for `The Shore and You` and `Travelogue` -- **Profile music control**: Added background music volume slider in the profile settings panel +- **Background music loop**: Global alternating playback for `The Shore and You` and `Travelogue` +- **Profile music control**: Background music volume slider in the profile settings panel +- **Beach shell sprites**: Repositioned shell sprites to the right side of the scene +- **Hand detection camera**: MediaPipe hand detection integration (hidden camera preview) ### Changed - **Frontend assets**: Reorganized static assets into `frontend/src/assets/images/` and `frontend/src/assets/audio/` -- **Asset imports**: Updated frontend image and audio references to match the new asset directory structure -- **Crab speech bubble anchoring**: Reworked the beach scene crab hint bubble to position from the crab sprite's rendered bounds instead of static offsets, keeping the bubble visually closer across viewport sizes. +- **Crab speech bubble anchoring**: Reworked hint bubble positioning to use rendered sprite bounds instead of static offsets +- **Pearl shell layout**: Adjusted pearl shell positioning; uses all photos in pearl shell + +### Fixed + +- Missing `autocomplete` attribute on nickname input +- Stray character in `App.vue` template +- Pearl shell layout positioning issues +- Camera preview visibility in pic wall + --- ## [1.0.0] - 2026-03-05 diff --git a/README.md b/README.md index 88d7965e..3389ba2e 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![CI](https://img.shields.io/github/actions/workflow/status/koishi510/MomShell/ci.yml?branch=main&style=flat&label=CI)](https://github.com/koishi510/MomShell/actions/workflows/ci.yml) [![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL%203.0-blue?style=flat)](LICENSE) -[![Go](https://img.shields.io/badge/Go-1.23-00ADD8?style=flat&logo=go&logoColor=white)](https://go.dev/) +[![Go](https://img.shields.io/badge/Go-1.25-00ADD8?style=flat&logo=go&logoColor=white)](https://go.dev/) [![Vue](https://img.shields.io/badge/Vue-3-4FC08D?style=flat&logo=vue.js&logoColor=white)](https://vuejs.org/) [![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?style=flat&logo=typescript&logoColor=white)](https://www.typescriptlang.org/) [![Docker](https://img.shields.io/badge/Docker-2496ED?style=flat&logo=docker&logoColor=white)](https://www.docker.com/) @@ -15,6 +15,10 @@ AI-powered postpartum recovery platform with emotional support, community connec |--------|-------------| | **Soul Companion** | AI chat companion with conversation memory and emotional support | | **Sisterhood Bond** | Community Q&A with verified healthcare professionals and content moderation | +| **Echo / Memoir** | Self-reflection space with AI-generated memoir stickers and partner connection | +| **Photo Gallery** | Photo wall with AI-generated images, lifecycle management, and drag/zoom UI | +| **Whisper** | Audio-to-text conversation using speech recognition | +| **Tasks** | Goal-setting and task-tracking system with partner support | | **Admin Panel** | Embedded single-page admin at `/admin` — dashboard, user CRUD, config management | ## Quick Start @@ -38,12 +42,17 @@ MomShell/ ├── backend/ # Go (Gin + GORM + PostgreSQL) │ ├── cmd/server/ # Entry point │ ├── internal/ # App code (handler/service/repository/model/dto) -│ │ └── admin/ # Embedded admin panel (go:embed) -│ └── pkg/ # Shared utilities (JWT, password, OpenAI) +│ │ ├── admin/ # Embedded admin panel (go:embed) +│ │ └── scheduler/ # Background job scheduling +│ └── pkg/ # Shared utilities (JWT, password, OpenAI, Firecrawl) ├── frontend/ # Vue 3 (Vite + TypeScript + Pinia) │ └── src/ +│ ├── components/ # Overlay panels + beach scene layers +│ ├── composables/# Animation, parallax, waves, music +│ └── stores/ # Pinia stores (auth, UI) ├── deploy/ # Docker Compose + Nginx ├── docs/ # Documentation +├── scripts/ # Development setup scripts └── Makefile # Development commands ``` diff --git a/SECURITY.md b/SECURITY.md index 8ec7d472..9bb03048 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,7 +4,8 @@ | Version | Supported | | ------- | ------------------ | -| 0.x.x | ✓ | +| 1.x.x | ✓ | +| 0.x.x | ✗ (archived) | ## Reporting a Vulnerability @@ -27,7 +28,7 @@ Instead, please report them via one of the following methods: Please include the following information in your report: - Type of vulnerability (e.g., SQL injection, XSS, authentication bypass) -- Affected component (Soul Companion, Recovery Coach, Community, Guardian Partner, API, etc.) +- Affected component (Soul Companion, Sisterhood Bond, Echo/Memoir, Photo Gallery, Whisper, Tasks, Admin Panel, API, etc.) - Step-by-step instructions to reproduce the issue - Proof-of-concept or exploit code (if possible) - Potential impact of the vulnerability @@ -47,19 +48,19 @@ When deploying MomShell, please ensure: 1. **Environment Variables** - Never commit `.env` files or API keys to version control - - Use secure secret management for `MODELSCOPE_KEY` + - Use secure secret management for `OPENAI_API_KEY` - Change `JWT_SECRET_KEY` to a secure random value in production - Rotate API keys periodically 2. **Network Security** - - Use HTTPS in production (configure SSL/TLS in nginx) + - Use HTTPS in production (configure SSL/TLS in Nginx) - Restrict API access to trusted origins (CORS configuration) - Use firewalls to limit exposed ports 3. **Database Security** - - Use strong, unique passwords for database access - - Regularly backup database files - - Restrict file permissions on SQLite database files + - Use strong, unique passwords for PostgreSQL access + - Regularly backup the database + - Restrict network access to the database port 4. **Docker Security** - Keep base images updated diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index d3390962..05548ce9 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -55,9 +55,9 @@ func Load() *Config { JWTRefreshTokenExpireDays: getEnvInt("JWT_REFRESH_TOKEN_EXPIRE_DAYS", 7), OpenAIAPIKey: getEnv("OPENAI_API_KEY", ""), OpenAIBaseURL: getEnv("OPENAI_BASE_URL", "https://api-inference.modelscope.cn/v1"), - OpenAIModel: getEnv("OPENAI_MODEL", "Qwen/Qwen2.5-72B-Instruct"), + OpenAIModel: getEnv("OPENAI_MODEL", "Qwen/Qwen3-235B-A22B"), FirecrawlAPIKey: getEnv("FIRECRAWL_API_KEY", ""), - ImageModel: getEnv("IMAGE_MODEL", ""), + ImageModel: getEnv("IMAGE_MODEL", "Tongyi-MAI/Z-Image-Turbo"), Port: getEnv("PORT", "8000"), CORSOrigins: getEnv("CORS_ORIGINS", "http://localhost:5173,http://localhost:8000,http://localhost:3000"), DBLogLevel: getEnv("DB_LOG_LEVEL", "warn"), diff --git a/backend/internal/fileutil/photo.go b/backend/internal/fileutil/photo.go new file mode 100644 index 00000000..87d245fa --- /dev/null +++ b/backend/internal/fileutil/photo.go @@ -0,0 +1,21 @@ +package fileutil + +import ( + "os" + "path/filepath" + "strings" +) + +// RemoveUploadedFile safely removes a file referenced by a URL-style path +// (e.g. "/uploads/photos/abc.png") from disk. filepath.Clean resolves any +// path traversal components, and the prefix check ensures the resolved path +// remains under the uploads directory. +func RemoveUploadedFile(imageURL string) { + if imageURL == "" { + return + } + localPath := filepath.Clean("." + imageURL) + if strings.HasPrefix(localPath, "uploads"+string(filepath.Separator)) { + _ = os.Remove(localPath) + } +} diff --git a/backend/internal/handler/admin.go b/backend/internal/handler/admin.go index e0a8a5a7..3f870498 100644 --- a/backend/internal/handler/admin.go +++ b/backend/internal/handler/admin.go @@ -31,28 +31,6 @@ func (h *AdminHandler) IsAdmin(userID string) bool { return user.IsAdmin } -// requireAdmin checks if the current user is an admin -func (h *AdminHandler) requireAdmin(c *gin.Context) (string, bool) { - userID := middleware.GetUserID(c) - if userID == "" { - c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权"}) - return "", false - } - - user, err := h.authService.GetUserByID(userID) - if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "用户不存在"}) - return "", false - } - - if !user.IsAdmin { - c.JSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"}) - return "", false - } - - return userID, true -} - // ServeAdminPage returns the embedded admin HTML page func (h *AdminHandler) ServeAdminPage(c *gin.Context) { c.Data(http.StatusOK, "text/html; charset=utf-8", admin.HTML) @@ -60,10 +38,6 @@ func (h *AdminHandler) ServeAdminPage(c *gin.Context) { // GetStats returns dashboard statistics func (h *AdminHandler) GetStats(c *gin.Context) { - if _, ok := h.requireAdmin(c); !ok { - return - } - stats, err := h.adminService.GetDashboardStats() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) @@ -75,10 +49,6 @@ func (h *AdminHandler) GetStats(c *gin.Context) { // ListUsers returns paginated user list func (h *AdminHandler) ListUsers(c *gin.Context) { - if _, ok := h.requireAdmin(c); !ok { - return - } - var params dto.AdminUserListParams if err := c.ShouldBindQuery(¶ms); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) @@ -96,10 +66,6 @@ func (h *AdminHandler) ListUsers(c *gin.Context) { // GetUser returns a single user's detail func (h *AdminHandler) GetUser(c *gin.Context) { - if _, ok := h.requireAdmin(c); !ok { - return - } - id := c.Param("id") detail, err := h.adminService.GetUser(id) if err != nil { @@ -112,10 +78,6 @@ func (h *AdminHandler) GetUser(c *gin.Context) { // CreateUser creates a new user func (h *AdminHandler) CreateUser(c *gin.Context) { - if _, ok := h.requireAdmin(c); !ok { - return - } - var req dto.AdminCreateUser if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误: " + err.Error()}) @@ -133,10 +95,7 @@ func (h *AdminHandler) CreateUser(c *gin.Context) { // UpdateUser updates user fields func (h *AdminHandler) UpdateUser(c *gin.Context) { - adminID, ok := h.requireAdmin(c) - if !ok { - return - } + adminID := middleware.GetUserID(c) id := c.Param("id") var req dto.AdminUserUpdate @@ -156,10 +115,7 @@ func (h *AdminHandler) UpdateUser(c *gin.Context) { // DeleteUser deletes a user func (h *AdminHandler) DeleteUser(c *gin.Context) { - adminID, ok := h.requireAdmin(c) - if !ok { - return - } + adminID := middleware.GetUserID(c) id := c.Param("id") if err := h.adminService.DeleteUser(id, adminID); err != nil { @@ -172,20 +128,12 @@ func (h *AdminHandler) DeleteUser(c *gin.Context) { // GetConfig returns configuration items func (h *AdminHandler) GetConfig(c *gin.Context) { - if _, ok := h.requireAdmin(c); !ok { - return - } - items := h.adminService.GetConfig() c.JSON(http.StatusOK, gin.H{"items": items}) } // UpdateConfig updates editable configuration items func (h *AdminHandler) UpdateConfig(c *gin.Context) { - if _, ok := h.requireAdmin(c); !ok { - return - } - var req dto.ConfigUpdateRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误: " + err.Error()}) @@ -202,10 +150,6 @@ func (h *AdminHandler) UpdateConfig(c *gin.Context) { // ListPhotos returns paginated photo list for admin func (h *AdminHandler) ListPhotos(c *gin.Context) { - if _, ok := h.requireAdmin(c); !ok { - return - } - var params dto.AdminPhotoListParams if err := c.ShouldBindQuery(¶ms); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) @@ -223,10 +167,6 @@ func (h *AdminHandler) ListPhotos(c *gin.Context) { // DeletePhoto deletes a photo by ID (admin) func (h *AdminHandler) DeletePhoto(c *gin.Context) { - if _, ok := h.requireAdmin(c); !ok { - return - } - id := c.Param("id") if err := h.adminService.DeletePhoto(id); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) diff --git a/backend/internal/handler/auth.go b/backend/internal/handler/auth.go index b391ec2c..1c455d4a 100644 --- a/backend/internal/handler/auth.go +++ b/backend/internal/handler/auth.go @@ -26,15 +26,19 @@ func NewAuthHandler(authService *service.AuthService, cfg *config.Config) *AuthH return &AuthHandler{authService: authService, cfg: cfg} } +// setRefreshCookie sets the refresh token as an httpOnly, Secure, SameSite=Lax +// cookie. The Secure flag is always true so the cookie is only sent over HTTPS. +// Browsers treat localhost as a secure context, so this works for local dev on +// http://localhost. For non-localhost plain HTTP (e.g. http://192.168.x.x) +// the cookie will not be sent back — use HTTPS or localhost in that case. func (h *AuthHandler) setRefreshCookie(c *gin.Context, refreshToken string, maxAge int) { - secure := !strings.Contains(h.cfg.CORSOrigins, "localhost") - c.SetCookie(refreshTokenCookie, refreshToken, maxAge, refreshCookiePath, "", secure, true) c.SetSameSite(http.SameSiteLaxMode) + c.SetCookie(refreshTokenCookie, refreshToken, maxAge, refreshCookiePath, "", true, true) } func (h *AuthHandler) clearRefreshCookie(c *gin.Context) { - secure := !strings.Contains(h.cfg.CORSOrigins, "localhost") - c.SetCookie(refreshTokenCookie, "", -1, refreshCookiePath, "", secure, true) + c.SetSameSite(http.SameSiteLaxMode) + c.SetCookie(refreshTokenCookie, "", -1, refreshCookiePath, "", true, true) } // POST /api/v1/auth/register diff --git a/backend/internal/middleware/security.go b/backend/internal/middleware/security.go index b0ce6c46..c98c4275 100644 --- a/backend/internal/middleware/security.go +++ b/backend/internal/middleware/security.go @@ -9,7 +9,7 @@ func SecurityHeaders() gin.HandlerFunc { c.Header("X-Content-Type-Options", "nosniff") c.Header("X-XSS-Protection", "1; mode=block") c.Header("Referrer-Policy", "strict-origin-when-cross-origin") - c.Header("Permissions-Policy", "camera=(), microphone=(), geolocation=()") + c.Header("Permissions-Policy", "camera=(self), microphone=(), geolocation=()") c.Next() } } diff --git a/backend/internal/repository/answer.go b/backend/internal/repository/answer.go index a3066e48..a9955696 100644 --- a/backend/internal/repository/answer.go +++ b/backend/internal/repository/answer.go @@ -1,12 +1,27 @@ package repository import ( - "fmt" - "github.com/momshell/backend/internal/model" "gorm.io/gorm" ) +var allowedAnswerSortColumns = map[string]string{ + "created_at": "created_at", + "like_count": "like_count", +} + +func sanitizeAnswerSort(sortBy, order string) string { + col, ok := allowedAnswerSortColumns[sortBy] + if !ok { + col = "created_at" + } + dir, ok := allowedSortOrders[order] + if !ok { + dir = "desc" + } + return col + " " + dir +} + type AnswerRepo struct { db *gorm.DB } @@ -36,9 +51,8 @@ func (r *AnswerRepo) FindByQuestionID( return nil, 0, err } - orderClause := fmt.Sprintf("%s %s", sortBy, order) var answers []model.Answer - err := query.Order(orderClause).Offset(offset).Limit(limit).Find(&answers).Error + err := query.Order(sanitizeAnswerSort(sortBy, order)).Offset(offset).Limit(limit).Find(&answers).Error return answers, total, err } diff --git a/backend/internal/repository/question.go b/backend/internal/repository/question.go index c7907112..4fbbfae4 100644 --- a/backend/internal/repository/question.go +++ b/backend/internal/repository/question.go @@ -1,12 +1,29 @@ package repository import ( - "fmt" - "github.com/momshell/backend/internal/model" "gorm.io/gorm" ) +var allowedQuestionSortColumns = map[string]string{ + "created_at": "questions.created_at", + "view_count": "questions.view_count", + "answer_count": "questions.answer_count", + "like_count": "questions.like_count", +} + +func sanitizeQuestionSort(sortBy, order string) string { + col, ok := allowedQuestionSortColumns[sortBy] + if !ok { + col = "questions.created_at" + } + dir, ok := allowedSortOrders[order] + if !ok { + dir = "desc" + } + return col + " " + dir +} + type QuestionRepo struct { db *gorm.DB } @@ -43,9 +60,8 @@ func (r *QuestionRepo) FindAll( return nil, 0, err } - orderClause := fmt.Sprintf("questions.%s %s", sortBy, order) var questions []model.Question - err := query.Order(orderClause).Offset(offset).Limit(limit).Find(&questions).Error + err := query.Order(sanitizeQuestionSort(sortBy, order)).Offset(offset).Limit(limit).Find(&questions).Error return questions, total, err } diff --git a/backend/internal/repository/sort.go b/backend/internal/repository/sort.go new file mode 100644 index 00000000..2d2ad281 --- /dev/null +++ b/backend/internal/repository/sort.go @@ -0,0 +1,8 @@ +package repository + +// allowedSortOrders is shared across question and answer repositories +// to validate user-supplied ORDER BY direction. +var allowedSortOrders = map[string]string{ + "asc": "asc", + "desc": "desc", +} diff --git a/backend/internal/scheduler/photo_cleanup.go b/backend/internal/scheduler/photo_cleanup.go index 41811f03..c10d936a 100644 --- a/backend/internal/scheduler/photo_cleanup.go +++ b/backend/internal/scheduler/photo_cleanup.go @@ -2,11 +2,9 @@ package scheduler import ( "log" - "os" - "path/filepath" - "strings" "time" + "github.com/momshell/backend/internal/fileutil" "github.com/momshell/backend/internal/repository" ) @@ -17,7 +15,7 @@ const ( ) // StartPhotoCleanup launches a background goroutine that periodically deletes -// photos with is_on_wall=false older than 30 days, including their disk files. +// photos with is_on_wall=false older than 365 days, including their disk files. func StartPhotoCleanup(photoRepo *repository.PhotoRepo) { go func() { log.Println("photo cleanup scheduler started") @@ -48,7 +46,7 @@ func runCleanup(photoRepo *repository.PhotoRepo) { } for _, p := range photos { - removePhotoFile(p.ImageURL) + fileutil.RemoveUploadedFile(p.ImageURL) if err := photoRepo.DeleteByID(p.ID); err != nil { log.Printf("photo cleanup: delete error id=%s: %v", p.ID, err) continue @@ -61,14 +59,3 @@ func runCleanup(photoRepo *repository.PhotoRepo) { log.Printf("photo cleanup: deleted %d expired photos", totalDeleted) } } - -func removePhotoFile(imageURL string) { - if imageURL == "" { - return - } - localPath := filepath.Clean("." + imageURL) - if strings.HasPrefix(localPath, "uploads"+string(filepath.Separator)) && - !strings.Contains(localPath, "..") { - _ = os.Remove(localPath) - } -} diff --git a/backend/internal/service/admin.go b/backend/internal/service/admin.go index e0b2d093..f2c14454 100644 --- a/backend/internal/service/admin.go +++ b/backend/internal/service/admin.go @@ -4,13 +4,13 @@ import ( "errors" "fmt" "os" - "path/filepath" "strconv" "strings" "sync" "github.com/momshell/backend/internal/config" "github.com/momshell/backend/internal/dto" + "github.com/momshell/backend/internal/fileutil" "github.com/momshell/backend/internal/model" "github.com/momshell/backend/internal/repository" "github.com/momshell/backend/pkg/password" @@ -273,11 +273,7 @@ func (s *AdminService) DeletePhoto(id string) error { } if photo.ImageURL != "" { - localPath := filepath.Clean("." + photo.ImageURL) - if strings.HasPrefix(localPath, "uploads"+string(filepath.Separator)) && - !strings.Contains(localPath, "..") { - _ = os.Remove(localPath) - } + fileutil.RemoveUploadedFile(photo.ImageURL) } return s.photoRepo.DeleteByID(id) diff --git a/backend/internal/service/photo.go b/backend/internal/service/photo.go index 4eed6bd1..153d0376 100644 --- a/backend/internal/service/photo.go +++ b/backend/internal/service/photo.go @@ -17,6 +17,7 @@ import ( "github.com/google/uuid" "github.com/momshell/backend/internal/dto" + "github.com/momshell/backend/internal/fileutil" "github.com/momshell/backend/internal/model" "github.com/momshell/backend/internal/repository" "github.com/momshell/backend/pkg/openai" @@ -186,14 +187,7 @@ func (s *PhotoService) DeletePhoto(id, userID string) error { } // Remove file from disk if it's a local upload - if photo.ImageURL != "" { - localPath := filepath.Clean("." + photo.ImageURL) - // Strict validation: must be under uploads/photos/ and match expected pattern - if strings.HasPrefix(localPath, "uploads"+string(filepath.Separator)) && - !strings.Contains(localPath, "..") { - _ = os.Remove(localPath) - } - } + fileutil.RemoveUploadedFile(photo.ImageURL) return s.photoRepo.Delete(id, userID) } diff --git a/docs/README.md b/docs/README.md index 0379779c..f93d1386 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,12 +7,20 @@ | [Configuration](configuration.md) | Environment variables reference | | [Architecture](architecture.md) | Technical design and project structure | | [Deployment](deployment.md) | Docker and production deployment | -| [Features](features.md) | Module descriptions | +| [Features](features.md) | Module descriptions (Soul Companion, Sisterhood Bond, Echo, Photo, Whisper, Tasks, Admin) | ## Contributing See [CONTRIBUTING.md](../CONTRIBUTING.md) for code standards and workflow. +## Other Documents + +| Document | Description | +|----------|-------------| +| [Changelog](../CHANGELOG.md) | Version history | +| [Security Policy](../SECURITY.md) | Vulnerability reporting | +| [Code of Conduct](../CODE_OF_CONDUCT.md) | Community standards | + --- [Back to main README](../README.md) diff --git a/docs/architecture.md b/docs/architecture.md index 2f291cdb..850d9553 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -8,11 +8,12 @@ Technical architecture overview of MomShell. | Technology | Purpose | |------------|---------| -| **Go 1.23** | Backend language | +| **Go 1.25** | Backend language | | **Gin** | HTTP framework | | **GORM** | ORM (PostgreSQL) | -| **JWT (golang-jwt)** | Authentication | -| **OpenAI SDK** | LLM integration | +| **JWT (golang-jwt)** | Authentication (httpOnly cookies) | +| **OpenAI SDK** | LLM integration (Qwen / any OpenAI-compatible) | +| **Firecrawl** | Web search for grounding AI responses | | **go:embed** | Embedded admin panel | ### Frontend @@ -24,6 +25,9 @@ Technical architecture overview of MomShell. | **TypeScript** | Type safety | | **Pinia** | State management | | **Axios** | HTTP client | +| **GSAP** | Animations | +| **Three.js** | 3D graphics | +| **MediaPipe** | Hand detection | ## Project Structure @@ -37,12 +41,14 @@ MomShell/ │ │ ├── database/ # DB connection & auto-migration │ │ ├── dto/ # Request/response data transfer objects │ │ ├── handler/ # HTTP handlers (Gin) -│ │ ├── middleware/ # Auth, CORS, recovery middleware +│ │ ├── middleware/ # Auth, CORS, recovery, rate limiting │ │ ├── model/ # GORM models (User, Question, Answer, etc.) │ │ ├── repository/ # Data access layer │ │ ├── router/ # Route registration +│ │ ├── scheduler/ # Background job scheduling (photo cleanup) │ │ └── service/ # Business logic │ └── pkg/ +│ ├── firecrawl/ # Web search API client │ ├── jwt/ # JWT generation & validation │ ├── openai/ # OpenAI-compatible client │ └── password/ # bcrypt hashing @@ -50,9 +56,9 @@ MomShell/ ├── frontend/ # Vue 3 frontend │ └── src/ │ ├── components/ -│ │ ├── overlay/ # Auth, chat, community, profile panels +│ │ ├── overlay/ # Auth, chat, community, profile, whisper, task panels │ │ └── scene/ # Beach scene layers (sky, ocean, sand, etc.) -│ ├── composables/ # Vue composables (animation, parallax, waves) +│ ├── composables/ # Vue composables (animation, parallax, waves, music) │ ├── constants/ # Scene configuration │ ├── lib/ # API client, auth utilities │ ├── stores/ # Pinia stores (auth, UI) @@ -60,7 +66,7 @@ MomShell/ │ ├── deploy/ # Docker Compose + Nginx config ├── docs/ # Documentation -├── scripts/dev-setup.sh # Development setup script +├── scripts/ # Development setup scripts ├── .env.example # Environment template ├── Makefile # Build & dev commands └── .pre-commit-config.yaml # Git hooks @@ -86,9 +92,10 @@ The admin panel is a single HTML file (`internal/admin/admin.html`) using Tailwi ### Authentication -- JWT access tokens (30 min) + refresh tokens (7 days) +- JWT access tokens (30 min) + refresh tokens (7 days), stored in httpOnly cookies - Tokens extracted from `Authorization: Bearer`, `X-Access-Token` header, or `access_token` cookie - Admin role verified per-request in handler via `authService.GetUserByID` +- Fixed-window rate limiting on all API endpoints ### Content Moderation diff --git a/docs/configuration.md b/docs/configuration.md index 012713e0..5070b2eb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -27,9 +27,18 @@ The setup script auto-generates `JWT_SECRET_KEY`. Change it manually for product | `OPENAI_API_KEY` | API key for LLM service | **Yes** | — | | `OPENAI_BASE_URL` | API base URL | No | `https://api-inference.modelscope.cn/v1` | | `OPENAI_MODEL` | Model name | No | `Qwen/Qwen3-235B-A22B` | +| `IMAGE_MODEL` | Model for AI image generation | No | `Tongyi-MAI/Z-Image-Turbo` | Any OpenAI-compatible API is supported (ModelScope, OpenAI, local Ollama, etc.). +## Web Search + +| Variable | Description | Required | Default | +|----------|-------------|----------|---------| +| `FIRECRAWL_API_KEY` | Firecrawl API key for web search grounding | No | — | + +When set, AI replies use web search to reduce hallucinations for factual questions. + ## Server | Variable | Description | Required | Default | diff --git a/docs/development.md b/docs/development.md index f00321f4..b56c391e 100644 --- a/docs/development.md +++ b/docs/development.md @@ -2,7 +2,7 @@ ## Prerequisites -- Go 1.23+ +- Go 1.25+ - Node.js 24+ - PostgreSQL - Git diff --git a/docs/features.md b/docs/features.md index b8a2eb38..ac764c7b 100644 --- a/docs/features.md +++ b/docs/features.md @@ -9,6 +9,7 @@ AI-powered emotional support companion. - **Empathetic conversations** designed for postpartum emotional support - **Conversation memory** for personalized experience across sessions - **Content moderation** with crisis keyword detection +- **Visual effects** — color tones and animations on AI responses ## Sisterhood Bond @@ -17,8 +18,42 @@ Community Q&A connecting mothers with verified healthcare professionals. - **Dual channels**: Professional advice and peer experience sharing - **Verified professionals**: Doctors, therapists, and nurses with credential verification - **Engagement**: Q&A, likes, collections, comments +- **AI auto-reply**: AI community assistant replies to posts and comments with source citations - **Content moderation**: Keyword-based filtering with manual review queue +## Echo / Memoir + +Self-reflection space for mothers to reconnect with their pre-motherhood identity. + +- **Identity tags**: Capture personal preferences (music, sounds, literature, memories) +- **AI-generated memoirs**: Nostalgic stories based on personal tags with editable text +- **Memoir covers**: AI-generated SVG gradient covers with image regeneration +- **Partner connection**: Partners can observe and support through a glass window metaphor + +## Photo Gallery + +Photo management with AI-powered image generation. + +- **Photo wall**: Interactive drag-and-zoom photo browsing (pic wall UI) +- **AI photo generation**: Generate photos in memoir using image model +- **Photo lifecycle**: Auto-cleanup of expired photos with admin controls +- **Pearl shell**: Photo display integrated into the beach scene + +## Whisper + +Audio-to-text conversation feature. + +- **Speech recognition**: Convert spoken words to text for the AI companion +- **Conque shell metaphor**: Listen and speak through the beach shell + +## Tasks + +Goal-setting and tracking system. + +- **Task creation**: Set personal recovery goals +- **Partner support**: Partners can participate in tasks with accept/reject workflow +- **Progress polling**: Real-time status updates on partner task completion + ## Admin Panel Embedded management interface at `/admin`. @@ -26,7 +61,9 @@ Embedded management interface at `/admin`. - **Dashboard**: User statistics, content counts, role distribution - **User management**: Search, filter, paginate, create, edit (role/status), delete - **Config management**: View and edit runtime configuration (API keys, token expiration) +- **Photo management**: Admin-level photo lifecycle controls - **Single-file UI**: Tailwind CSS + Alpine.js, embedded via `go:embed` +- **Frontend access**: Admin panel also accessible through the Vue frontend navigation --- diff --git a/docs/getting-started.md b/docs/getting-started.md index 97cfac1b..d376e8c1 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -2,7 +2,7 @@ ## Prerequisites -- [Go 1.23+](https://go.dev/dl/) +- [Go 1.25+](https://go.dev/dl/) - [Node.js 24+](https://nodejs.org/) (or via [nvm](https://github.com/nvm-sh/nvm)) - [PostgreSQL](https://www.postgresql.org/) - Git diff --git a/frontend/src/components/overlay/ChatPanel.vue b/frontend/src/components/overlay/ChatPanel.vue index 62e9e3b9..2a7c8f46 100644 --- a/frontend/src/components/overlay/ChatPanel.vue +++ b/frontend/src/components/overlay/ChatPanel.vue @@ -107,9 +107,17 @@ const colorToneMap: Record = { } function generateSessionId(): string { + // crypto.randomUUID() requires a secure context (HTTPS or localhost). + // Fall back to crypto.getRandomValues for non-secure HTTP environments. if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID() } + if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') { + const bytes = new Uint8Array(16) + crypto.getRandomValues(bytes) + return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('') + } + // Last resort: non-crypto fallback for environments without Web Crypto API return Date.now().toString(36) + Math.random().toString(36).slice(2) } diff --git a/frontend/src/lib/apiClient.ts b/frontend/src/lib/apiClient.ts index 0d9ebf5a..86ac6e17 100644 --- a/frontend/src/lib/apiClient.ts +++ b/frontend/src/lib/apiClient.ts @@ -10,6 +10,12 @@ const API_BASE = import.meta.env.VITE_API_BASE_URL || ""; // In-memory access token — not stored in localStorage let currentAccessToken: string | null = null; +// Callback for notifying external consumers (e.g. Pinia store) of token changes. +// Only one callback is supported — subsequent calls to setOnTokenRefreshed +// overwrite the previous one. This is intentional for a single-page app with +// one auth store instance. +let onTokenRefreshed: ((token: string | null) => void) | null = null; + export function setAccessToken(token: string | null) { currentAccessToken = token; } @@ -18,6 +24,10 @@ export function getAccessToken(): string | null { return currentAccessToken; } +export function setOnTokenRefreshed(cb: (token: string | null) => void) { + onTokenRefreshed = cb; +} + const apiClient: AxiosInstance = axios.create({ baseURL: API_BASE, headers: { "Content-Type": "application/json" }, @@ -81,6 +91,11 @@ apiClient.interceptors.response.use( const resp = await apiRefresh(); currentAccessToken = resp.access_token; + // Notify store so isAuthenticated stays in sync + if (onTokenRefreshed) { + onTokenRefreshed(resp.access_token); + } + if (originalRequest.headers) { originalRequest.headers.Authorization = `Bearer ${resp.access_token}`; } @@ -90,6 +105,9 @@ apiClient.interceptors.response.use( } catch (refreshError) { processQueue(refreshError as Error, null); currentAccessToken = null; + if (onTokenRefreshed) { + onTokenRefreshed(null); + } return Promise.reject(refreshError); } finally { isRefreshing = false; diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index 6304d231..4339a58d 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -112,12 +112,22 @@ export function apiSetRole( }); } -export function apiLogout(accessToken: string): Promise { +export function apiLogout(accessToken?: string): Promise { + const headers: Record = {}; + if (accessToken) { + headers.Authorization = `Bearer ${accessToken}`; + } return fetch(`${AUTH_API}/logout`, { method: "POST", - headers: { - Authorization: `Bearer ${accessToken}`, - }, + headers, credentials: "include", - }).then(() => undefined); + }).then(async (response) => { + if (!response.ok) { + const err = await response.json().catch(() => ({})); + if (typeof (err as Record).error === "string") { + throw new Error((err as Record).error); + } + throw new Error("退出登录失败"); + } + }); } diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index 3e527416..98df53da 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -11,7 +11,7 @@ import { apiSetRole, apiLogout, } from "@/lib/auth"; -import { setAccessToken } from "@/lib/apiClient"; +import { setAccessToken, setOnTokenRefreshed } from "@/lib/apiClient"; export const useAuthStore = defineStore("auth", () => { const user = ref(null); @@ -26,6 +26,14 @@ export const useAuthStore = defineStore("auth", () => { setAccessToken(token); }); + // Keep store in sync when apiClient refreshes the token via interceptor + setOnTokenRefreshed((token) => { + accessToken.value = token; + if (!token) { + user.value = null; + } + }); + async function init() { // Try to restore session via httpOnly refresh cookie const refreshed = await refreshAuth(); @@ -58,6 +66,8 @@ export const useAuthStore = defineStore("auth", () => { } function logout() { + // The /logout endpoint requires authentication, so only call the server + // when we have a token. The server clears the httpOnly refresh cookie. if (accessToken.value) { apiLogout(accessToken.value).catch(() => {}); }