diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 6f2a430..a2d69f0 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -101,6 +101,27 @@ func (h *Handler) GetMe(w http.ResponseWriter, r *http.Request) { h.writeJSON(w, http.StatusOK, user) } +func (h *Handler) UpdatePassword(w http.ResponseWriter, r *http.Request) { + claims, ok := r.Context().Value(auth.UserContextKey).(*auth.Claims) + if !ok { + h.writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + + var req models.ChangePasswordRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + h.writeError(w, http.StatusBadRequest, "invalid request payload") + return + } + + if err := h.auth.UpdatePassword(claims.UserID, req.OldPassword, req.NewPassword); err != nil { + h.writeError(w, http.StatusBadRequest, err.Error()) + return + } + + h.writeJSON(w, http.StatusOK, map[string]string{"message": "password updated successfully"}) +} + // Stats Handler func (h *Handler) GetStats(w http.ResponseWriter, r *http.Request) { stats, err := h.db.GetStats() diff --git a/internal/api/server.go b/internal/api/server.go index eee9610..5a75487 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -44,6 +44,7 @@ func NewRouter(h *Handler, authService *auth.AuthService, staticDir string) http r.Use(authService.Middleware) r.Get("/auth/me", h.GetMe) + r.Put("/auth/password", h.UpdatePassword) r.Get("/stats", h.GetStats) // Categories diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 928e696..c0ee437 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -84,6 +84,29 @@ func (s *AuthService) Authenticate(username, password string) (string, error) { return tokenString, nil } +func (s *AuthService) UpdatePassword(userID, oldPassword, newPassword string) error { + var currentHash string + err := s.db.QueryRow("SELECT password_hash FROM users WHERE id = ?", userID).Scan(¤tHash) + if err != nil { + return errors.New("user not found") + } + + if err := bcrypt.CompareHashAndPassword([]byte(currentHash), []byte(oldPassword)); err != nil { + return errors.New("incorrect current password") + } + + if len(strings.TrimSpace(newPassword)) < 6 { + return errors.New("new password must be at least 6 characters long") + } + + newHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("failed to hash new password: %w", err) + } + + return s.db.UpdateUserPassword(userID, string(newHash)) +} + func (s *AuthService) ValidateToken(tokenString string) (*Claims, error) { token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..f71ea6c --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,87 @@ +package auth + +import ( + "os" + "path/filepath" + "testing" + + "git.pengzhan.dev/haopengzhan/rsspipe/internal/config" + "git.pengzhan.dev/haopengzhan/rsspipe/internal/db" +) + +func TestAuthService(t *testing.T) { + tempDir, err := os.MkdirTemp("", "rsspipe-auth-test-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer func() { + _ = os.RemoveAll(tempDir) + }() + + dbPath := filepath.Join(tempDir, "auth_test.db") + database, err := db.InitDB(dbPath) + if err != nil { + t.Fatalf("InitDB failed: %v", err) + } + defer func() { + _ = database.Close() + }() + + cfg := &config.Config{ + AdminUsername: "admin", + AdminPassword: "initial_password_123", + JWTSecret: "super-secret-jwt-key-for-test", + } + + authService := NewAuthService(database, cfg) + + // 1. Bootstrap Admin + if err := authService.BootstrapAdmin(); err != nil { + t.Fatalf("BootstrapAdmin failed: %v", err) + } + + // 2. Authenticate with initial password + token, err := authService.Authenticate("admin", "initial_password_123") + if err != nil { + t.Fatalf("Authenticate failed: %v", err) + } + if token == "" { + t.Fatalf("expected non-empty token") + } + + // 3. Validate Token + claims, err := authService.ValidateToken(token) + if err != nil { + t.Fatalf("ValidateToken failed: %v", err) + } + if claims.Username != "admin" { + t.Errorf("expected username admin, got %s", claims.Username) + } + + // 4. Update Password + if err := authService.UpdatePassword(claims.UserID, "wrong_old_pwd", "new_secure_pwd_456"); err == nil { + t.Errorf("expected error with incorrect old password") + } + + if err := authService.UpdatePassword(claims.UserID, "initial_password_123", "short"); err == nil { + t.Errorf("expected error with too short new password") + } + + if err := authService.UpdatePassword(claims.UserID, "initial_password_123", "new_secure_pwd_456"); err != nil { + t.Fatalf("UpdatePassword failed: %v", err) + } + + // 5. Authenticate with new password + newToken, err := authService.Authenticate("admin", "new_secure_pwd_456") + if err != nil { + t.Fatalf("Authenticate with new password failed: %v", err) + } + if newToken == "" { + t.Fatalf("expected non-empty token for new password") + } + + // 6. Old password should now fail + if _, err := authService.Authenticate("admin", "initial_password_123"); err == nil { + t.Errorf("expected failure when authenticating with old password") + } +} diff --git a/internal/db/db.go b/internal/db/db.go index 1804c24..933bb0a 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -173,6 +173,15 @@ func (d *DB) CreateUser(username, passwordHash string) (*models.User, error) { return u, nil } +func (d *DB) UpdateUserPassword(userID, passwordHash string) error { + now := time.Now().UTC() + _, err := d.Exec( + "UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?", + passwordHash, now, userID, + ) + return err +} + // Category operations func (d *DB) ListCategories() ([]models.Category, error) { query := ` diff --git a/internal/db/db_test.go b/internal/db/db_test.go index 57b3dff..42a1d32 100644 --- a/internal/db/db_test.go +++ b/internal/db/db_test.go @@ -102,4 +102,17 @@ func TestDatabaseOperations(t *testing.T) { if stats.TotalFeeds != 1 || stats.TotalUnread != 1 || stats.TotalStarred != 1 || stats.TotalCategories != 1 { t.Errorf("unexpected stats: %+v", stats) } + + // 8. Test UpdateUserPassword + newHash := "$2a$10$updatedhashvalue" + if err := database.UpdateUserPassword(u.ID, newHash); err != nil { + t.Fatalf("UpdateUserPassword failed: %v", err) + } + updatedUser, err := database.GetUserByUsername("admin") + if err != nil { + t.Fatalf("GetUserByUsername failed: %v", err) + } + if updatedUser.PasswordHash != newHash { + t.Errorf("expected hash %s, got %s", newHash, updatedUser.PasswordHash) + } } diff --git a/internal/models/models.go b/internal/models/models.go index b18fdd3..6cf9aaf 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -116,3 +116,9 @@ type MarkAllReadRequest struct { CategoryID *string `json:"category_id,omitempty"` Before *string `json:"before,omitempty"` } + +// ChangePasswordRequest is the body for PUT /api/auth/password +type ChangePasswordRequest struct { + OldPassword string `json:"old_password"` + NewPassword string `json:"new_password"` +} diff --git a/web/src/App.tsx b/web/src/App.tsx index 11200a6..5f75239 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -43,6 +43,8 @@ export const App: React.FC = () => { return localStorage.getItem('rsspipe_selected_entry_id'); }); + const [activeArticle, setActiveArticle] = useState(null); + const [categories, setCategories] = useState([]); const [feeds, setFeeds] = useState([]); const [entries, setEntries] = useState([]); @@ -121,19 +123,11 @@ export const App: React.FC = () => { search: filter.search, limit: 100, }); - setEntries((prev) => { - if (filter.view === 'inbox' && selectedEntryId) { - const currentActive = prev.find((e) => e.id === selectedEntryId); - if (currentActive && !res.entries.some((e) => e.id === selectedEntryId)) { - return [currentActive, ...res.entries]; - } - } - return res.entries; - }); + setEntries(res.entries); } catch (err) { console.error('Failed to load entries:', err); } - }, [currentUser, filter, selectedEntryId]); + }, [currentUser, filter]); // Initial load useEffect(() => { @@ -143,27 +137,41 @@ export const App: React.FC = () => { } }, [currentUser, loadSidebarData, loadEntries]); - // Sync focused index with persisted selectedEntryId + // Sync focused index & active article when entries change without disrupting reading state useEffect(() => { if (entries.length > 0) { + if (activeArticle) { + const found = entries.find((e) => e.id === activeArticle.id); + if (found) { + setActiveArticle(found); + setFocusedIndex(entries.findIndex((e) => e.id === activeArticle.id)); + return; + } + // Active article remains in reader canvas from prior feed, but list is unselected + setFocusedIndex(-1); + return; + } + if (selectedEntryId) { - const idx = entries.findIndex((e) => e.id === selectedEntryId); - if (idx >= 0) { - setFocusedIndex(idx); + const found = entries.find((e) => e.id === selectedEntryId); + if (found) { + setActiveArticle(found); + setFocusedIndex(entries.findIndex((e) => e.id === selectedEntryId)); return; } } + setFocusedIndex(0); - if (entries[0]) { - setSelectedEntryId(entries[0].id); - localStorage.setItem('rsspipe_selected_entry_id', entries[0].id); - } + setActiveArticle(entries[0]); + setSelectedEntryId(entries[0].id); + localStorage.setItem('rsspipe_selected_entry_id', entries[0].id); } - }, [entries, selectedEntryId]); + }, [entries]); const handleSelectEntry = (index: number) => { setFocusedIndex(index); if (entries[index]) { + setActiveArticle(entries[index]); setSelectedEntryId(entries[index].id); localStorage.setItem('rsspipe_selected_entry_id', entries[index].id); } @@ -211,6 +219,9 @@ export const App: React.FC = () => { item.id === entry.id ? { ...item, is_read: newStatus } : item ) ); + if (activeArticle && activeArticle.id === entry.id) { + setActiveArticle((prev) => (prev ? { ...prev, is_read: newStatus } : prev)); + } try { await api.updateRead(entry.id, newStatus); @@ -223,6 +234,9 @@ export const App: React.FC = () => { item.id === entry.id ? { ...item, is_read: !newStatus } : item ) ); + if (activeArticle && activeArticle.id === entry.id) { + setActiveArticle((prev) => (prev ? { ...prev, is_read: !newStatus } : prev)); + } } }; @@ -237,6 +251,9 @@ export const App: React.FC = () => { item.id === entry.id ? { ...item, is_starred: newStatus } : item ) ); + if (activeArticle && activeArticle.id === entry.id) { + setActiveArticle((prev) => (prev ? { ...prev, is_starred: newStatus } : prev)); + } try { await api.updateStar(entry.id, newStatus); @@ -248,6 +265,9 @@ export const App: React.FC = () => { item.id === entry.id ? { ...item, is_starred: !newStatus } : item ) ); + if (activeArticle && activeArticle.id === entry.id) { + setActiveArticle((prev) => (prev ? { ...prev, is_starred: !newStatus } : prev)); + } } }; @@ -256,6 +276,9 @@ export const App: React.FC = () => { try { await api.markAllRead(filter.feedId, filter.categoryId); setEntries((prev) => prev.map((item) => ({ ...item, is_read: true }))); + if (activeArticle) { + setActiveArticle((prev) => (prev ? { ...prev, is_read: true } : prev)); + } loadSidebarData(); } catch (err) { console.error('Failed to mark all read:', err); @@ -309,63 +332,11 @@ export const App: React.FC = () => { case 'j': case 'J': // Next item e.preventDefault(); - setFocusedIndex((prev) => { - const next = Math.min(prev + 1, Math.max(0, entries.length - 1)); - if (entries[next]) { - setSelectedEntryId(entries[next].id); - localStorage.setItem('rsspipe_selected_entry_id', entries[next].id); - } - return next; - }); - break; - - case 'k': - case 'K': // Previous item - e.preventDefault(); - setFocusedIndex((prev) => { - const next = Math.max(prev - 1, 0); - if (entries[next]) { - setSelectedEntryId(entries[next].id); - localStorage.setItem('rsspipe_selected_entry_id', entries[next].id); - } - return next; - }); - break; - - case 'v': - case 'V': // Open original link - if (entries[focusedIndex]?.url) { - e.preventDefault(); - window.open(entries[focusedIndex].url, '_blank'); - } - break; - - case 's': - case 'S': // Toggle Star - if (entries[focusedIndex]) { - e.preventDefault(); - handleToggleStar(entries[focusedIndex]); - } - break; - - case 'm': - case 'M': // Toggle Read / Unread - if (entries[focusedIndex]) { - e.preventDefault(); - handleToggleRead(entries[focusedIndex]); - } - break; - - case 'e': - case 'E': // Archive / Mark read and advance - if (entries[focusedIndex]) { - e.preventDefault(); - if (!entries[focusedIndex].is_read) { - handleToggleRead(entries[focusedIndex]); - } + if (entries.length > 0) { setFocusedIndex((prev) => { - const next = Math.min(prev + 1, Math.max(0, entries.length - 1)); + const next = prev < 0 ? 0 : Math.min(prev + 1, entries.length - 1); if (entries[next]) { + setActiveArticle(entries[next]); setSelectedEntryId(entries[next].id); localStorage.setItem('rsspipe_selected_entry_id', entries[next].id); } @@ -374,6 +345,79 @@ export const App: React.FC = () => { } break; + case 'k': + case 'K': // Previous item + e.preventDefault(); + if (entries.length > 0) { + setFocusedIndex((prev) => { + const next = prev < 0 ? 0 : Math.max(prev - 1, 0); + if (entries[next]) { + setActiveArticle(entries[next]); + setSelectedEntryId(entries[next].id); + localStorage.setItem('rsspipe_selected_entry_id', entries[next].id); + } + return next; + }); + } + break; + + case 'v': + case 'V': // Open original link + { + const targetUrl = activeArticle?.url || entries[focusedIndex]?.url; + if (targetUrl) { + e.preventDefault(); + window.open(targetUrl, '_blank'); + } + } + break; + + case 's': + case 'S': // Toggle Star + { + const target = activeArticle || entries[focusedIndex]; + if (target) { + e.preventDefault(); + handleToggleStar(target); + } + } + break; + + case 'm': + case 'M': // Toggle Read / Unread + { + const target = activeArticle || entries[focusedIndex]; + if (target) { + e.preventDefault(); + handleToggleRead(target); + } + } + break; + + case 'e': + case 'E': // Archive / Mark read and advance + { + const target = activeArticle || entries[focusedIndex]; + if (target) { + e.preventDefault(); + if (!target.is_read) { + handleToggleRead(target); + } + if (entries.length > 0) { + setFocusedIndex((prev) => { + const next = prev < 0 ? 0 : Math.min(prev + 1, entries.length - 1); + if (entries[next]) { + setActiveArticle(entries[next]); + setSelectedEntryId(entries[next].id); + localStorage.setItem('rsspipe_selected_entry_id', entries[next].id); + } + return next; + }); + } + } + } + break; + case 'a': case 'A': // Add feed modal if (!e.shiftKey && !e.metaKey && !e.ctrlKey) { @@ -421,7 +465,7 @@ export const App: React.FC = () => { window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [entries, focusedIndex]); + }, [entries, focusedIndex, activeArticle]); if (authLoading) { return ( @@ -467,6 +511,7 @@ export const App: React.FC = () => { stats={stats} entries={entries} selectedIndex={focusedIndex} + activeArticle={activeArticle} onSelectEntry={handleSelectEntry} onToggleRead={handleToggleRead} onToggleStar={handleToggleStar} diff --git a/web/src/components/SettingsView.tsx b/web/src/components/SettingsView.tsx index 3734253..bcf1af2 100644 --- a/web/src/components/SettingsView.tsx +++ b/web/src/components/SettingsView.tsx @@ -20,6 +20,8 @@ import { Clock, Timer, Eye, + Lock, + Key, } from 'lucide-react'; interface SettingsViewProps { @@ -71,6 +73,43 @@ export const SettingsView: React.FC = ({ const [opmlLoading, setOpmlLoading] = useState(false); const [opmlResult, setOpmlResult] = useState(null); + // Change Password state + const [oldPassword, setOldPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [passwordLoading, setPasswordLoading] = useState(false); + const [passwordError, setPasswordError] = useState(null); + const [passwordSuccess, setPasswordSuccess] = useState(null); + + const handleChangePassword = async (e: React.FormEvent) => { + e.preventDefault(); + setPasswordError(null); + setPasswordSuccess(null); + + if (newPassword.length < 6) { + setPasswordError('New password must be at least 6 characters long'); + return; + } + + if (newPassword !== confirmPassword) { + setPasswordError('New passwords do not match'); + return; + } + + setPasswordLoading(true); + try { + await api.changePassword(oldPassword, newPassword); + setPasswordSuccess('Password updated successfully! Please use your new password next time you log in.'); + setOldPassword(''); + setNewPassword(''); + setConfirmPassword(''); + } catch (err: any) { + setPasswordError(err.message || 'Failed to update password'); + } finally { + setPasswordLoading(false); + } + }; + // Filter feeds const filteredFeeds = feeds.filter( (f) => @@ -633,6 +672,87 @@ export const SettingsView: React.FC = ({ +
+

+ + Change Password +

+

+ Update your single-user administrator password. Minimum 6 characters required. +

+ + {passwordSuccess && ( +
+ + {passwordSuccess} +
+ )} + + {passwordError && ( +
+ + {passwordError} +
+ )} + +
+
+ + setOldPassword(e.target.value)} + required + /> +
+ +
+ + setNewPassword(e.target.value)} + required + /> +
+ +
+ + setConfirmPassword(e.target.value)} + required + /> +
+ + +
+
+

Storage & Engine Stats

diff --git a/web/src/components/ThreePaneView.tsx b/web/src/components/ThreePaneView.tsx index 53aa97c..79cf64e 100644 --- a/web/src/components/ThreePaneView.tsx +++ b/web/src/components/ThreePaneView.tsx @@ -20,6 +20,7 @@ interface ThreePaneViewProps { stats: Stats | null; entries: FeedEntry[]; selectedIndex: number; + activeArticle?: FeedEntry | null; onSelectEntry: (index: number) => void; onToggleRead: (entry: FeedEntry, e?: React.MouseEvent) => void; onToggleStar: (entry: FeedEntry, e?: React.MouseEvent) => void; @@ -35,6 +36,7 @@ export const ThreePaneView: React.FC = ({ stats, entries, selectedIndex, + activeArticle, onSelectEntry, onToggleRead, onToggleStar, @@ -44,7 +46,7 @@ export const ThreePaneView: React.FC = ({ const cardRefs = useRef<(HTMLDivElement | null)[]>([]); const readerPaneRef = useRef(null); - const selectedEntry = entries[selectedIndex] || entries[0]; + const selectedEntry = activeArticle || entries[selectedIndex] || entries[0]; // Auto-scroll list to active card useEffect(() => { diff --git a/web/src/index.css b/web/src/index.css index a3d6d9e..da22ad7 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -1130,3 +1130,17 @@ html, body, #root { font-size: 0.8rem; margin-bottom: 1.1rem; } + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.animate-spin { + animation: spin 1s linear infinite; +} + diff --git a/web/src/services/api.ts b/web/src/services/api.ts index efbed79..122889f 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -64,6 +64,13 @@ export const api = { return request('/auth/me'); }, + async changePassword(old_password: string, new_password: string): Promise<{ message: string }> { + return request<{ message: string }>('/auth/password', { + method: 'PUT', + body: JSON.stringify({ old_password, new_password }), + }); + }, + // Stats async getStats(): Promise { return request('/stats');