Skip to content
Merged

Dev #143

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 53 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Comment on lines +14 to +21
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The changelog entry says "auth tokens" were migrated to httpOnly cookies and mentions token extraction via "header, cookie". In this PR, only the refresh token is cookie-based; access tokens are kept in-memory and sent via Authorization/X-Access-Token, and cookie-based access-token extraction was removed. Please adjust these bullet points so the changelog reflects the actual auth model.

Copilot uses AI. Check for mistakes.
#### 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
Expand Down
15 changes: 12 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/)
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The README and docs now require Go 1.25, but the repository Dockerfile still builds the backend with golang:1.23-alpine, which will fail once go.mod requires 1.25. Update the build image/CI to match the documented (and go.mod) Go version.

Copilot uses AI. Check for mistakes.
[![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/)
Expand All @@ -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
Expand All @@ -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
```

Expand Down
15 changes: 8 additions & 7 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@

| Version | Supported |
| ------- | ------------------ |
| 0.x.x | ✓ |
| 1.x.x | ✓ |
| 0.x.x | ✗ (archived) |

## Reporting a Vulnerability

Expand All @@ -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
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions backend/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
21 changes: 21 additions & 0 deletions backend/internal/fileutil/photo.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
64 changes: 2 additions & 62 deletions backend/internal/handler/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,39 +31,13 @@ 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)
}

// 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()})
Expand All @@ -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(&params); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
Expand All @@ -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 {
Expand All @@ -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()})
Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -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()})
Expand All @@ -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(&params); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
Expand All @@ -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()})
Expand Down
12 changes: 8 additions & 4 deletions backend/internal/handler/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion backend/internal/middleware/security.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
Loading